├── public
├── favicon.ico
├── favicon-16x16.png
└── apple-touch-icon.png
├── postcss.config.js
├── .gitignore
├── next-env.d.ts
├── components
├── confetti.tsx
├── chat-list.tsx
├── llm-stocks
│ ├── spinner.tsx
│ ├── stocks-skeleton.tsx
│ ├── events-skeleton.tsx
│ ├── stock-skeleton.tsx
│ ├── event.tsx
│ ├── index.tsx
│ ├── stocks.tsx
│ ├── message.tsx
│ ├── stock-purchase.tsx
│ └── stock.tsx
├── providers.tsx
├── footer.tsx
├── external-link.tsx
├── ui
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── separator.tsx
│ ├── input.tsx
│ ├── toaster.tsx
│ ├── tooltip.tsx
│ ├── button.tsx
│ ├── use-toast.ts
│ ├── toast.tsx
│ └── icons.tsx
├── header.tsx
├── pacman.tsx
└── empty-screen.tsx
├── components.json
├── lib
├── rate-limit.ts
├── hooks
│ ├── use-at-bottom.tsx
│ ├── use-enter-submit.tsx
│ └── chat-scroll-anchor.tsx
└── utils
│ ├── tool-definition.ts
│ └── index.tsx
├── tsconfig.json
├── app
├── globals.css
├── layout.tsx
├── page.tsx
└── action.tsx
├── package.json
├── tailwind.config.ts
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rauchg/genui-demo/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rauchg/genui-demo/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rauchg/genui-demo/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | .turbo
4 | *.log
5 | .next
6 | *.local
7 | .env
8 | .cache
9 | .turbo
10 | .vercel
11 | .env*.local
12 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/components/confetti.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import JSConfetti from "js-confetti";
3 | import { useEffect } from "react";
4 |
5 | export function Confetti() {
6 | useEffect(() => {
7 | const jsConfetti = new JSConfetti();
8 | jsConfetti.addConfetti();
9 | }, []);
10 |
11 | return "Here you go 🎉!";
12 | }
13 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/chat-list.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@/components/ui/separator';
2 |
3 | export function ChatList({ messages }: { messages: any[] }) {
4 | if (!messages.length) {
5 | return null;
6 | }
7 |
8 | return (
9 |
10 | {messages.map((message, index) => (
11 |
12 | {message.display}
13 |
14 | ))}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/components/llm-stocks/spinner.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export const spinner = (
4 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/components/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
5 | import { ThemeProviderProps } from 'next-themes/dist/types';
6 |
7 | import { TooltipProvider } from '@/components/ui/tooltip';
8 |
9 | export function Providers({ children, ...props }: ThemeProviderProps) {
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/lib/rate-limit.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit";
2 | import { Redis } from "@upstash/redis";
3 |
4 | if (!process.env.KV_REST_API_URL || !process.env.KV_REST_API_TOKEN) {
5 | throw new Error(
6 | "Please link a Vercel KV instance or populate `KV_REST_API_URL` and `KV_REST_API_TOKEN`",
7 | );
8 | }
9 |
10 | const redis = new Redis({
11 | url: process.env.KV_REST_API_URL,
12 | token: process.env.KV_REST_API_TOKEN,
13 | });
14 |
15 | export const messageRateLimit = new Ratelimit({
16 | redis,
17 | limiter: Ratelimit.slidingWindow(10, "15 m"),
18 | analytics: true,
19 | prefix: "ratelimit:geui:msg",
20 | });
21 |
--------------------------------------------------------------------------------
/lib/hooks/use-at-bottom.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export function useAtBottom(offset = 0) {
4 | const [isAtBottom, setIsAtBottom] = React.useState(false);
5 |
6 | React.useEffect(() => {
7 | const handleScroll = () => {
8 | setIsAtBottom(
9 | window.innerHeight + window.scrollY >=
10 | document.body.offsetHeight - offset,
11 | );
12 | };
13 |
14 | window.addEventListener('scroll', handleScroll, { passive: true });
15 | handleScroll();
16 |
17 | return () => {
18 | window.removeEventListener('scroll', handleScroll);
19 | };
20 | }, [offset]);
21 |
22 | return isAtBottom;
23 | }
24 |
--------------------------------------------------------------------------------
/components/llm-stocks/stocks-skeleton.tsx:
--------------------------------------------------------------------------------
1 | export const StocksSkeleton = () => {
2 | return (
3 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 | import { ExternalLink } from '@/components/external-link';
5 |
6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
7 | return (
8 |
15 | Open source AI chatbot built with{' '}
16 | Next.js and{' '}
17 | Vercel AI SDK .
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/llm-stocks/events-skeleton.tsx:
--------------------------------------------------------------------------------
1 | export const EventsSkeleton = () => {
2 | return (
3 |
4 |
5 |
6 | {'xxxxx'}
7 |
8 |
9 | {'xxxxxxxxxxx'}
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/lib/hooks/use-enter-submit.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, type RefObject } from 'react';
2 |
3 | export function useEnterSubmit(): {
4 | formRef: RefObject;
5 | onKeyDown: (event: React.KeyboardEvent) => void;
6 | } {
7 | const formRef = useRef(null);
8 |
9 | const handleKeyDown = (
10 | event: React.KeyboardEvent,
11 | ): void => {
12 | if (
13 | event.key === 'Enter' &&
14 | !event.shiftKey &&
15 | !event.nativeEvent.isComposing
16 | ) {
17 | formRef.current?.requestSubmit();
18 | event.preventDefault();
19 | }
20 | };
21 |
22 | return { formRef, onKeyDown: handleKeyDown };
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/components/external-link.tsx:
--------------------------------------------------------------------------------
1 | export function ExternalLink({
2 | href,
3 | children,
4 | }: {
5 | href: string;
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 |
14 | {children}
15 |
22 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as LabelPrimitive from '@radix-ui/react-label';
5 | import { cva, type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/lib/hooks/chat-scroll-anchor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { useInView } from 'react-intersection-observer';
5 | import { useAtBottom } from './use-at-bottom';
6 |
7 | interface ChatScrollAnchorProps {
8 | trackVisibility?: boolean;
9 | }
10 |
11 | export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
12 | const isAtBottom = useAtBottom();
13 | const { ref, entry, inView } = useInView({
14 | trackVisibility,
15 | delay: 100,
16 | rootMargin: '0px 0px -50px 0px',
17 | });
18 |
19 | React.useEffect(() => {
20 | if (isAtBottom && trackVisibility && !inView) {
21 | entry?.target.scrollIntoView({
22 | block: 'start',
23 | });
24 | }
25 | }, [inView, entry, isAtBottom, trackVisibility]);
26 |
27 | return
;
28 | }
29 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = 'Textarea';
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/components/llm-stocks/stock-skeleton.tsx:
--------------------------------------------------------------------------------
1 | export const StockSkeleton = () => {
2 | return (
3 |
4 |
5 | xxxxxxx
6 |
7 |
8 | xxxx
9 |
10 |
11 | xxxx
12 |
13 |
14 | xxxxxx xxx xx xxxx xx xxx
15 |
16 |
17 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = 'horizontal', decorative = true, ...props },
14 | ref,
15 | ) => (
16 |
27 | ),
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = 'Input';
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@/components/ui/toast';
11 | import { useToast } from '@/components/ui/use-toast';
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast();
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | );
31 | })}
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/llm-stocks/event.tsx:
--------------------------------------------------------------------------------
1 | import { format, parseISO } from 'date-fns';
2 |
3 | interface Event {
4 | date: string;
5 | headline: string;
6 | description: string;
7 | }
8 |
9 | export function Events({ events }: { events: Event[] }) {
10 | return (
11 |
12 | {events.map(event => (
13 |
17 |
18 | {format(parseISO(event.date), 'dd LLL, yyyy')}
19 |
20 |
21 | {event.headline.slice(0, 30)}
22 |
23 |
24 | {event.description.slice(0, 70)}...
25 |
26 |
27 | ))}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/components/llm-stocks/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import dynamic from 'next/dynamic';
4 | import { StockSkeleton } from './stock-skeleton';
5 | import { StocksSkeleton } from './stocks-skeleton';
6 | import { EventsSkeleton } from './events-skeleton';
7 |
8 | export { spinner } from './spinner';
9 | export { BotCard, BotMessage, SystemMessage } from './message';
10 |
11 | const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), {
12 | ssr: false,
13 | loading: () => ,
14 | });
15 |
16 | const Purchase = dynamic(
17 | () => import('./stock-purchase').then(mod => mod.Purchase),
18 | {
19 | ssr: false,
20 | loading: () => (
21 |
22 | Loading stock info...
23 |
24 | ),
25 | },
26 | );
27 |
28 | const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), {
29 | ssr: false,
30 | loading: () => ,
31 | });
32 |
33 | const Events = dynamic(() => import('./event').then(mod => mod.Events), {
34 | ssr: false,
35 | loading: () => ,
36 | });
37 |
38 | export { Stock, Purchase, Stocks, Events };
39 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider;
9 |
10 | const Tooltip = TooltipPrimitive.Root;
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger;
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
31 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --muted: 240 4.8% 95.9%;
10 | --muted-foreground: 240 3.8% 46.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --card: 0 0% 100%;
14 | --card-foreground: 240 10% 3.9%;
15 | --border: 240 5.9% 90%;
16 | --input: 240 5.9% 90%;
17 | --primary: 240 5.9% 10%;
18 | --primary-foreground: 0 0% 98%;
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 | --accent: 240 4.8% 95.9%;
22 | --accent-foreground: ;
23 | --destructive: 0 84.2% 60.2%;
24 | --destructive-foreground: 0 0% 98%;
25 | --ring: 240 5% 64.9%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 240 10% 3.9%;
31 | --foreground: 0 0% 98%;
32 | --muted: 240 3.7% 15.9%;
33 | --muted-foreground: 240 5% 64.9%;
34 | --popover: 240 10% 3.9%;
35 | --popover-foreground: 0 0% 98%;
36 | --card: 240 10% 3.9%;
37 | --card-foreground: 0 0% 98%;
38 | --border: 240 3.7% 15.9%;
39 | --input: 240 3.7% 15.9%;
40 | --primary: 0 0% 98%;
41 | --primary-foreground: 240 5.9% 10%;
42 | --secondary: 240 3.7% 15.9%;
43 | --secondary-foreground: 0 0% 98%;
44 | --accent: 240 3.7% 15.9%;
45 | --accent-foreground: ;
46 | --destructive: 0 62.8% 30.6%;
47 | --destructive-foreground: 0 85.7% 97.3%;
48 | --ring: 240 3.7% 15.9%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/lib/utils/tool-definition.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | /**
4 | * Any array of ToolDefinitions.
5 | */
6 | export type TAnyToolDefinitionArray = Array<
7 | ToolDefinition
8 | >;
9 |
10 | /**
11 | * A map of ToolDefinitions, indexed by name.
12 | */
13 | export type TAnyToolDefinitionMap = Readonly<{
14 | [K in string]: ToolDefinition;
15 | }>;
16 |
17 | /**
18 | * Helper type to create a map of ToolDefinitions, indexed by name, from an array of ToolDefinitions.
19 | */
20 | export type TToolDefinitionMap<
21 | TToolDefinitionArray extends TAnyToolDefinitionArray,
22 | > = TToolDefinitionArray extends [infer TFirst, ...infer Rest]
23 | ? TFirst extends TAnyToolDefinitionArray[number]
24 | ? Rest extends TAnyToolDefinitionArray
25 | ? Readonly<{ [K in TFirst['name']]: TFirst }> & TToolDefinitionMap
26 | : never
27 | : never
28 | : Readonly<{}>;
29 |
30 | /**
31 | * A tool definition contains all information required for a language model to generate tool calls.
32 | */
33 | export interface ToolDefinition<
34 | NAME extends string,
35 | PARAMETERS extends z.AnyZodObject,
36 | > {
37 | /**
38 | * The name of the tool.
39 | * Should be understandable for language models and unique among the tools that they know.
40 | *
41 | * Note: Using generics to enable result type inference when there are multiple tool calls.
42 | */
43 | name: NAME;
44 |
45 | /**
46 | * A optional description of what the tool does. Will be used by the language model to decide whether to use the tool.
47 | */
48 | description?: string;
49 |
50 | /**
51 | * The schema of the input that the tool expects. The language model will use this to generate the input.
52 | * Use descriptions to make the input understandable for the language model.
53 | */
54 | parameters: PARAMETERS;
55 | }
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ai-rsc-demo",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"",
11 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx}\" --cache"
12 | },
13 | "dependencies": {
14 | "@radix-ui/react-icons": "^1.3.0",
15 | "@radix-ui/react-label": "^2.0.2",
16 | "@radix-ui/react-separator": "^1.0.3",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@radix-ui/react-toast": "^1.1.5",
19 | "@radix-ui/react-tooltip": "^1.0.7",
20 | "@upstash/ratelimit": "^1.0.1",
21 | "@upstash/redis": "^1.28.4",
22 | "@vercel/analytics": "^1.2.2",
23 | "@vercel/kv": "^1.0.1",
24 | "ai": "3.0.2",
25 | "class-variance-authority": "^0.7.0",
26 | "clsx": "^2.1.0",
27 | "d3-scale": "^4.0.2",
28 | "date-fns": "^3.3.1",
29 | "geist": "^1.2.2",
30 | "js-confetti": "^0.12.0",
31 | "next": "14.1.2-canary.3",
32 | "next-themes": "^0.2.1",
33 | "openai": "^4.27.0",
34 | "react": "^18",
35 | "react-dom": "^18",
36 | "react-intersection-observer": "^9.8.0",
37 | "react-pacman": "^0.0.4",
38 | "react-swipeable": "^7.0.1",
39 | "react-textarea-autosize": "^8.5.3",
40 | "sass": "^1.71.1",
41 | "tailwind-merge": "^2.2.1",
42 | "tailwindcss-animate": "^1.0.7",
43 | "usehooks-ts": "^2.15.1",
44 | "zod": "3.22.4",
45 | "zod-to-json-schema": "3.22.4"
46 | },
47 | "devDependencies": {
48 | "@types/d3-scale": "^4.0.8",
49 | "@types/node": "^20",
50 | "@types/react": "^18",
51 | "@types/react-dom": "^18",
52 | "eslint": "8.57.0",
53 | "eslint-config-next": "14.1.0",
54 | "postcss": "^8",
55 | "prettier": "^3.2.5",
56 | "tailwindcss": "^3.4.1",
57 | "typescript": "^5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/components/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import {
4 | IconGitHub,
5 | IconSeparator,
6 | IconSparkles,
7 | IconVercel,
8 | } from '@/components/ui/icons';
9 | import { Button } from '@/components/ui/button';
10 |
11 | export async function Header() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | AI
23 |
24 |
25 |
26 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/llm-stocks/stocks.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useActions, useUIState } from 'ai/rsc';
4 |
5 | import type { AI } from '../../app/action';
6 |
7 | export function Stocks({ stocks }: { stocks: any[] }) {
8 | const [, setMessages] = useUIState();
9 | const { submitUserMessage } = useActions();
10 |
11 | return (
12 |
13 | {stocks.map(stock => (
14 |
{
18 | const response = await submitUserMessage(`View ${stock.symbol}`);
19 | setMessages(currentMessages => [...currentMessages, response]);
20 | }}
21 | >
22 | 0 ? 'text-green-600' : 'text-red-600'
25 | } p-2 w-11 bg-white/10 flex flex-row justify-center rounded-md`}
26 | >
27 | {stock.delta > 0 ? '↑' : '↓'}
28 |
29 |
30 |
{stock.symbol}
31 |
${stock.price}
32 |
33 |
34 |
0 ? 'text-green-600' : 'text-red-600'
37 | } bold uppercase text-right`}
38 | >
39 | {` ${((stock.delta / stock.price) * 100).toFixed(2)}%`}
40 |
41 |
0 ? 'text-green-700' : 'text-red-700'
44 | } text-base text-right`}
45 | >
46 | {stock.delta}
47 |
48 |
49 |
50 | ))}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium shadow ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
16 | outline:
17 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
20 | ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground',
21 | link: 'text-primary underline-offset-4 shadow-none hover:underline',
22 | },
23 | size: {
24 | default: 'h-8 px-4 py-2',
25 | sm: 'h-8 rounded-md px-3',
26 | lg: 'h-11 rounded-md px-8',
27 | icon: 'h-8 w-8 p-0',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | },
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : 'button';
46 | return (
47 |
52 | );
53 | },
54 | );
55 | Button.displayName = 'Button';
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/components/llm-stocks/message.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { IconAI, IconUser } from '@/components/ui/icons';
4 | import { cn } from '@/lib/utils';
5 |
6 | // Different types of message bubbles.
7 |
8 | export function UserMessage({ children }: { children: React.ReactNode }) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {children}
16 |
17 |
18 | );
19 | }
20 |
21 | export function BotMessage({
22 | children,
23 | className,
24 | }: {
25 | children: React.ReactNode;
26 | className?: string;
27 | }) {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | {children}
35 |
36 |
37 | );
38 | }
39 |
40 | export function BotCard({
41 | children,
42 | showAvatar = true,
43 | }: {
44 | children: React.ReactNode;
45 | showAvatar?: boolean;
46 | }) {
47 | return (
48 |
49 |
55 |
56 |
57 |
{children}
58 |
59 | );
60 | }
61 |
62 | export function SystemMessage({ children }: { children: React.ReactNode }) {
63 | return (
64 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { GeistMono } from 'geist/font/mono';
3 | import { GeistSans } from 'geist/font/sans';
4 | import { Analytics } from '@vercel/analytics/react';
5 | import { Toaster } from '@/components/ui/toaster';
6 | import './globals.css';
7 |
8 | import { AI } from './action';
9 | import { Header } from '@/components/header';
10 | import { Providers } from '@/components/providers';
11 |
12 | const meta = {
13 | title: 'AI RSC Demo',
14 | description:
15 | 'Demo of an interactive financial assistant built using Next.js and Vercel AI SDK.',
16 | };
17 | export const metadata: Metadata = {
18 | ...meta,
19 | title: {
20 | default: 'AI RSC Demo',
21 | template: `%s - AI RSC Demo`,
22 | },
23 | icons: {
24 | icon: '/favicon.ico',
25 | shortcut: '/favicon-16x16.png',
26 | apple: '/apple-touch-icon.png',
27 | },
28 | twitter: {
29 | ...meta,
30 | card: 'summary_large_image',
31 | site: '@vercel',
32 | },
33 | openGraph: {
34 | ...meta,
35 | locale: 'en-US',
36 | type: 'website',
37 | },
38 | };
39 |
40 | export const viewport = {
41 | themeColor: [
42 | { media: '(prefers-color-scheme: light)', color: 'white' },
43 | { media: '(prefers-color-scheme: dark)', color: 'black' },
44 | ],
45 | };
46 |
47 | export default function RootLayout({
48 | children,
49 | }: Readonly<{
50 | children: React.ReactNode;
51 | }>) {
52 | return (
53 |
54 |
57 |
58 |
59 |
65 |
66 |
67 |
68 | {children}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
79 | export const runtime = 'edge';
80 |
--------------------------------------------------------------------------------
/components/pacman.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // @ts-ignore
4 | import ReactPacman from "react-pacman";
5 | import { useState, useEffect, useRef } from "react";
6 | import { useSwipeable } from "react-swipeable";
7 |
8 | export const EAST = 0;
9 | export const NORTH = 1;
10 | export const WEST = 2;
11 | export const SOUTH = 3;
12 |
13 | export function Pacman() {
14 | const [isGameStopped, setStopGame] = useState(false);
15 | const [isMobile, setIsMobile] = useState(null);
16 | const ref = useRef(null);
17 | const handlers = useSwipeable({
18 | onSwiped: (eventData) => {
19 | switch (eventData.dir) {
20 | case "Up":
21 | ref.current?.changeDirection(NORTH);
22 | break;
23 | case "Down":
24 | ref.current?.changeDirection(SOUTH);
25 | break;
26 | case "Left":
27 | ref.current?.changeDirection(WEST);
28 | break;
29 | case "Right":
30 | ref.current?.changeDirection(EAST);
31 | break;
32 | }
33 | },
34 | preventScrollOnSwipe: true,
35 | });
36 |
37 | useEffect(() => {
38 | setIsMobile(window.innerWidth <= 640);
39 | window.addEventListener("resize", () => {
40 | setIsMobile(window.innerWidth <= 640);
41 | });
42 | }, []);
43 | return (
44 |
45 | {isGameStopped ? (
46 |
Thanks for playing react-pacman!
47 | ) : (
48 |
49 | {isMobile && (
50 |
Swipe on the board to move
51 | )}
52 |
53 |
54 |
55 |
56 | Courtesy of{" "}
57 |
62 | react-pacman
63 | {" "}
64 | (MIT) /{" "}
65 | setStopGame(true)}
68 | >
69 | Stop game
70 |
71 |
72 |
73 | )}
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { fontFamily } from 'tailwindcss/defaultTheme';
2 | import type { Config } from 'tailwindcss';
3 |
4 | const config: Config = {
5 | content: [
6 | './ai_user_components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | './components/**/*.{js,ts,jsx,tsx}',
9 | './ai_hooks/**/*.{js,ts,jsx,tsx}',
10 | ],
11 | theme: {
12 | extend: {
13 | fontFamily: {
14 | sans: ['var(--font-geist-sans)', ...fontFamily.sans],
15 | mono: ['var(--font-geist-mono)', ...fontFamily.mono],
16 | },
17 | backgroundImage: {
18 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
19 | 'gradient-conic':
20 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
21 | },
22 | colors: {
23 | green: {
24 | '50': '#f0fdf6',
25 | '100': '#dbfdec',
26 | '200': '#baf8d9',
27 | '300': '#68eeac',
28 | '400': '#47e195',
29 | '500': '#1fc876',
30 | '600': '#13a65e',
31 | '700': '#13824c',
32 | '800': '#146740',
33 | '900': '#135436',
34 | '950': '#042f1c',
35 | },
36 | border: 'hsl(var(--border))',
37 | input: 'hsl(var(--input))',
38 | ring: 'hsl(var(--ring))',
39 | background: 'hsl(var(--background))',
40 | foreground: 'hsl(var(--foreground))',
41 | primary: {
42 | DEFAULT: 'hsl(var(--primary))',
43 | foreground: 'hsl(var(--primary-foreground))',
44 | },
45 | secondary: {
46 | DEFAULT: 'hsl(var(--secondary))',
47 | foreground: 'hsl(var(--secondary-foreground))',
48 | },
49 | destructive: {
50 | DEFAULT: 'hsl(var(--destructive))',
51 | foreground: 'hsl(var(--destructive-foreground))',
52 | },
53 | muted: {
54 | DEFAULT: 'hsl(var(--muted))',
55 | foreground: 'hsl(var(--muted-foreground))',
56 | },
57 | accent: {
58 | DEFAULT: 'hsl(var(--accent))',
59 | foreground: 'hsl(var(--accent-foreground))',
60 | },
61 | popover: {
62 | DEFAULT: 'hsl(var(--popover))',
63 | foreground: 'hsl(var(--popover-foreground))',
64 | },
65 | card: {
66 | DEFAULT: 'hsl(var(--card))',
67 | foreground: 'hsl(var(--card-foreground))',
68 | },
69 | },
70 | },
71 | },
72 | plugins: [require('tailwindcss-animate')],
73 | };
74 | export default config;
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Generative UI Demo
4 |
5 |
6 |
7 | An experimental preview of AI SDK 3.0 with Generative UI support
8 |
9 |
10 | ## Features
11 |
12 | - [Next.js](https://nextjs.org) App Router + React Server Components
13 | - [Vercel AI SDK 3.0](https://sdk.vercel.ai/docs) for Generative UI
14 | - OpenAI Tools/Function Calling
15 | - [shadcn/ui](https://ui.shadcn.com)
16 |
17 | ## Quick Links
18 |
19 | - [Read the blog post](https://vercel.com/blog/ai-sdk-3-generative-ui)
20 | - [See the demo](https://sdk.vercel.ai/demo)
21 | - [Visit the docs](https://sdk.vercel.ai/docs/concepts/ai-rsc)
22 |
23 | ## Deploy Your Own
24 |
25 | You can deploy your own version of the demo to Vercel with one click:
26 |
27 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai%2Fblob%2Fmain%2Fexamples%2Fnext-ai-rsc&env=OPENAI_API_KEY&envDescription=OpenAI%20API%20Key&envLink=https%3A%2F%2Fplatform.openai.com%2Fapi-keys)
28 |
29 | ## Running locally
30 |
31 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
32 |
33 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts.
34 |
35 | 1. Install Vercel CLI: `npm i -g vercel`
36 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
37 | 3. Download your environment variables: `vercel env pull`
38 |
39 | ```bash
40 | pnpm install
41 | pnpm dev
42 | ```
43 |
44 | Your app should now be running on [localhost:3000](http://localhost:3000/).
45 |
46 | ## Authors
47 |
48 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from:
49 |
50 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com)
51 | - Max Leiter ([@max_leiter](https://twitter.com/max_leiter)) - [Vercel](https://vercel.com)
52 | - Jeremy Philemon ([@jeremyphilemon](https://github.com/jeremyphilemon)) - [Vercel](https://vercel.com)
53 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com)
54 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com)
55 |
--------------------------------------------------------------------------------
/components/empty-screen.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ExternalLink } from "@/components/external-link";
3 | import { IconArrowRight } from "@/components/ui/icons";
4 |
5 | const exampleMessages = [
6 | {
7 | heading: "Play pacman",
8 | message: "Play pacman",
9 | },
10 | {
11 | heading: "Throw confetti",
12 | message: "Throw confetti",
13 | },
14 | {
15 | heading: "What are the trending stocks?",
16 | message: "What are the trending stocks?",
17 | },
18 | {
19 | heading: "What's the stock price of AAPL?",
20 | message: "What's the stock price of AAPL?",
21 | },
22 | {
23 | heading: "I'd like to buy 10 shares of MSFT",
24 | message: "I'd like to buy 10 shares of MSFT",
25 | },
26 | ];
27 |
28 | export function EmptyScreen({
29 | submitMessage,
30 | }: {
31 | submitMessage: (message: string) => void;
32 | }) {
33 | return (
34 |
35 |
36 |
37 | Welcome to AI SDK 3.0 Generative UI demo!
38 |
39 |
40 | This is a demo of an interactive financial assistant. It can show you
41 | stocks, tell you their prices, and even help you buy shares.
42 |
43 |
44 | The demo is built with{" "}
45 | Next.js and the{" "}
46 |
47 | Vercel AI SDK
48 |
49 | .
50 |
51 |
52 | It uses{" "}
53 |
54 | React Server Components
55 | {" "}
56 | to combine text with UI generated as output of the LLM. The UI state
57 | is synced through the SDK so the model is aware of your interactions
58 | as they happen.
59 |
60 |
Try an example:
61 |
62 | {exampleMessages.map((message, index) => (
63 | {
68 | submitMessage(message.message);
69 | }}
70 | >
71 |
72 | {message.heading}
73 |
74 | ))}
75 |
76 |
77 |
78 | Note: This is not real financial advice.
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/lib/utils/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | TAnyToolDefinitionArray,
3 | TToolDefinitionMap,
4 | } from '@/lib/utils/tool-definition';
5 | import { OpenAIStream } from 'ai';
6 | import type OpenAI from 'openai';
7 | import zodToJsonSchema from 'zod-to-json-schema';
8 | import { type ClassValue, clsx } from 'clsx';
9 | import { twMerge } from 'tailwind-merge';
10 | import { z } from 'zod';
11 |
12 | const consumeStream = async (stream: ReadableStream) => {
13 | const reader = stream.getReader();
14 | while (true) {
15 | const { done } = await reader.read();
16 | if (done) break;
17 | }
18 | };
19 |
20 | export function runOpenAICompletion<
21 | T extends Omit<
22 | Parameters[0],
23 | 'functions'
24 | >,
25 | const TFunctions extends TAnyToolDefinitionArray,
26 | >(
27 | openai: OpenAI,
28 | params: T & {
29 | functions: TFunctions;
30 | },
31 | ) {
32 | let text = '';
33 | let hasFunction = false;
34 |
35 | type TToolMap = TToolDefinitionMap;
36 | let onTextContent: (text: string, isFinal: boolean) => void = () => {};
37 |
38 | const functionsMap: Record = {};
39 | for (const fn of params.functions) {
40 | functionsMap[fn.name] = fn;
41 | }
42 |
43 | let onFunctionCall = {} as any;
44 |
45 | const { functions, ...rest } = params;
46 |
47 | (async () => {
48 | consumeStream(
49 | OpenAIStream(
50 | (await openai.chat.completions.create({
51 | ...rest,
52 | stream: true,
53 | functions: functions.map(fn => ({
54 | name: fn.name,
55 | description: fn.description,
56 | parameters: zodToJsonSchema(fn.parameters) as Record<
57 | string,
58 | unknown
59 | >,
60 | })),
61 | })) as any,
62 | {
63 | async experimental_onFunctionCall(functionCallPayload) {
64 | hasFunction = true;
65 |
66 | if (!onFunctionCall[functionCallPayload.name]) {
67 | return;
68 | }
69 |
70 | // we need to convert arguments from z.input to z.output
71 | // this is necessary if someone uses a .default in their schema
72 | const zodSchema = functionsMap[functionCallPayload.name].parameters;
73 | const parsedArgs = zodSchema.safeParse(
74 | functionCallPayload.arguments,
75 | );
76 |
77 | if (!parsedArgs.success) {
78 | throw new Error(
79 | `Invalid function call in message. Expected a function call object`,
80 | );
81 | }
82 |
83 | onFunctionCall[functionCallPayload.name]?.(parsedArgs.data);
84 | },
85 | onToken(token) {
86 | text += token;
87 | if (text.startsWith('{')) return;
88 | onTextContent(text, false);
89 | },
90 | onFinal() {
91 | if (hasFunction) return;
92 | onTextContent(text, true);
93 | },
94 | },
95 | ),
96 | );
97 | })();
98 |
99 | return {
100 | onTextContent: (
101 | callback: (text: string, isFinal: boolean) => void | Promise,
102 | ) => {
103 | onTextContent = callback;
104 | },
105 | onFunctionCall: (
106 | name: TName,
107 | callback: (
108 | args: z.output<
109 | TName extends keyof TToolMap
110 | ? TToolMap[TName] extends infer TToolDef
111 | ? TToolDef extends TAnyToolDefinitionArray[number]
112 | ? TToolDef['parameters']
113 | : never
114 | : never
115 | : never
116 | >,
117 | ) => void | Promise,
118 | ) => {
119 | onFunctionCall[name] = callback;
120 | },
121 | };
122 | }
123 |
124 | export function cn(...inputs: ClassValue[]) {
125 | return twMerge(clsx(inputs));
126 | }
127 |
128 | export const formatNumber = (value: number) =>
129 | new Intl.NumberFormat('en-US', {
130 | style: 'currency',
131 | currency: 'USD',
132 | }).format(value);
133 |
134 | export const runAsyncFnWithoutBlocking = (
135 | fn: (...args: any) => Promise,
136 | ) => {
137 | fn();
138 | };
139 |
140 | export const sleep = (ms: number) =>
141 | new Promise(resolve => setTimeout(resolve, ms));
142 |
143 | // Fake data
144 | export function getStockPrice(name: string) {
145 | let total = 0;
146 | for (let i = 0; i < name.length; i++) {
147 | total = (total + name.charCodeAt(i) * 9999121) % 9999;
148 | }
149 | return total / 100;
150 | }
151 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from 'react';
3 |
4 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: 'ADD_TOAST',
18 | UPDATE_TOAST: 'UPDATE_TOAST',
19 | DISMISS_TOAST: 'DISMISS_TOAST',
20 | REMOVE_TOAST: 'REMOVE_TOAST',
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType['ADD_TOAST'];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType['UPDATE_TOAST'];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType['DISMISS_TOAST'];
43 | toastId?: ToasterToast['id'];
44 | }
45 | | {
46 | type: ActionType['REMOVE_TOAST'];
47 | toastId?: ToasterToast['id'];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: 'REMOVE_TOAST',
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case 'ADD_TOAST':
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case 'UPDATE_TOAST':
81 | return {
82 | ...state,
83 | toasts: state.toasts.map(t =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t,
85 | ),
86 | };
87 |
88 | case 'DISMISS_TOAST': {
89 | const { toastId } = action;
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId);
95 | } else {
96 | state.toasts.forEach(toast => {
97 | addToRemoveQueue(toast.id);
98 | });
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map(t =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t,
110 | ),
111 | };
112 | }
113 | case 'REMOVE_TOAST':
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | };
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter(t => t.id !== action.toastId),
123 | };
124 | }
125 | };
126 |
127 | const listeners: Array<(state: State) => void> = [];
128 |
129 | let memoryState: State = { toasts: [] };
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action);
133 | listeners.forEach(listener => {
134 | listener(memoryState);
135 | });
136 | }
137 |
138 | type Toast = Omit;
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId();
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: 'UPDATE_TOAST',
146 | toast: { ...props, id },
147 | });
148 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
149 |
150 | dispatch({
151 | type: 'ADD_TOAST',
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: open => {
157 | if (!open) dismiss();
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState);
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState);
174 | return () => {
175 | const index = listeners.indexOf(setState);
176 | if (index > -1) {
177 | listeners.splice(index, 1);
178 | }
179 | };
180 | }, [state]);
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
186 | };
187 | }
188 |
189 | export { useToast, toast };
190 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Cross2Icon } from '@radix-ui/react-icons';
3 | import * as ToastPrimitives from '@radix-ui/react-toast';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
27 | {
28 | variants: {
29 | variant: {
30 | default: 'border bg-background text-foreground',
31 | destructive:
32 | 'destructive group border-destructive bg-destructive text-destructive-foreground',
33 | },
34 | },
35 | defaultVariants: {
36 | variant: 'default',
37 | },
38 | },
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | );
53 | });
54 | Toast.displayName = ToastPrimitives.Root.displayName;
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ));
69 | ToastAction.displayName = ToastPrimitives.Action.displayName;
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ));
87 | ToastClose.displayName = ToastPrimitives.Close.displayName;
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ));
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef;
114 |
115 | type ToastActionElement = React.ReactElement;
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | };
128 |
--------------------------------------------------------------------------------
/components/llm-stocks/stock-purchase.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useId, useState } from 'react';
4 | import { useActions, useAIState, useUIState } from 'ai/rsc';
5 | import { formatNumber } from '@/lib/utils';
6 |
7 | import type { AI } from '../../app/action';
8 |
9 | export function Purchase({
10 | defaultAmount,
11 | name,
12 | price,
13 | }: {
14 | defaultAmount?: number;
15 | name: string;
16 | price: number;
17 | }) {
18 | const [value, setValue] = useState(defaultAmount || 100);
19 | const [purchasingUI, setPurchasingUI] = useState(
20 | null,
21 | );
22 | const [history, setHistory] = useAIState();
23 | const [, setMessages] = useUIState();
24 | const { confirmPurchase } = useActions();
25 |
26 | // Unique identifier for this UI component.
27 | const id = useId();
28 |
29 | // Whenever the slider changes, we need to update the local value state and the history
30 | // so LLM also knows what's going on.
31 | function onSliderChange(e: React.ChangeEvent) {
32 | const newValue = Number(e.target.value);
33 | setValue(newValue);
34 |
35 | // Insert a hidden history info to the list.
36 | const info = {
37 | role: 'system' as const,
38 | content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${(
39 | newValue * price
40 | ).toFixed(2)}]`,
41 |
42 | // Identifier of this UI component, so we don't insert it many times.
43 | id,
44 | };
45 |
46 | // If last history state is already this info, update it. This is to avoid
47 | // adding every slider change to the history.
48 | if (history[history.length - 1]?.id === id) {
49 | setHistory([...history.slice(0, -1), info]);
50 | return;
51 | }
52 |
53 | // If it doesn't exist, append it to history.
54 | setHistory([...history, info]);
55 | }
56 |
57 | return (
58 |
59 |
60 | +1.23% ↑
61 |
62 |
{name}
63 |
${price}
64 | {purchasingUI ? (
65 |
{purchasingUI}
66 | ) : (
67 | <>
68 |
69 |
Shares to purchase
70 |
79 |
80 | 10
81 |
82 |
83 | 100
84 |
85 |
86 | 500
87 |
88 |
89 | 1000
90 |
91 |
92 |
93 |
94 |
Total cost
95 |
96 |
97 | {value}
98 |
99 | shares
100 |
101 |
102 |
×
103 |
104 | ${price}
105 |
106 | per share
107 |
108 |
109 |
110 | = {formatNumber(value * price)}
111 |
112 |
113 |
114 |
115 |
{
118 | const response = await confirmPurchase(name, price, value);
119 | setPurchasingUI(response.purchasingUI);
120 |
121 | // Insert a new system message to the UI.
122 | setMessages((currentMessages: any) => [
123 | ...currentMessages,
124 | response.newMessage,
125 | ]);
126 | }}
127 | >
128 | Purchase
129 |
130 | >
131 | )}
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useRef, useState } from 'react';
4 |
5 | import { useUIState, useActions, useAIState } from 'ai/rsc';
6 | import { UserMessage } from '@/components/llm-stocks/message';
7 |
8 | import { type AI } from './action';
9 | import { ChatScrollAnchor } from '@/lib/hooks/chat-scroll-anchor';
10 | import { FooterText } from '@/components/footer';
11 | import Textarea from 'react-textarea-autosize';
12 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit';
13 | import {
14 | Tooltip,
15 | TooltipContent,
16 | TooltipTrigger,
17 | } from '@/components/ui/tooltip';
18 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons';
19 | import { Button } from '@/components/ui/button';
20 | import { ChatList } from '@/components/chat-list';
21 | import { EmptyScreen } from '@/components/empty-screen';
22 | import { toast } from '@/components/ui/use-toast';
23 |
24 | export default function Page() {
25 | const [messages, setMessages] = useUIState();
26 | const { submitUserMessage } = useActions();
27 | const [inputValue, setInputValue] = useState('');
28 | const { formRef, onKeyDown } = useEnterSubmit();
29 | const inputRef = useRef(null);
30 |
31 | useEffect(() => {
32 | const handleKeyDown = (e: KeyboardEvent) => {
33 | if (e.key === '/') {
34 | if (
35 | e.target &&
36 | ['INPUT', 'TEXTAREA'].includes((e.target as any).nodeName)
37 | ) {
38 | return;
39 | }
40 | e.preventDefault();
41 | e.stopPropagation();
42 | if (inputRef?.current) {
43 | inputRef.current.focus();
44 | }
45 | }
46 | };
47 |
48 | document.addEventListener('keydown', handleKeyDown);
49 |
50 | return () => {
51 | document.removeEventListener('keydown', handleKeyDown);
52 | };
53 | }, [inputRef]);
54 |
55 | return (
56 |
57 |
58 | {messages.length ? (
59 | <>
60 |
61 | >
62 | ) : (
63 | {
65 | // Add user message UI
66 | setMessages(currentMessages => [
67 | ...currentMessages,
68 | {
69 | id: Date.now(),
70 | display: {message} ,
71 | },
72 | ]);
73 |
74 | // Submit and get response message
75 | const responseMessage = await submitUserMessage(message);
76 | setMessages(currentMessages => [
77 | ...currentMessages,
78 | responseMessage,
79 | ]);
80 | }}
81 | />
82 | )}
83 |
84 |
85 |
178 |
179 | );
180 | }
181 |
--------------------------------------------------------------------------------
/app/action.tsx:
--------------------------------------------------------------------------------
1 | import "server-only";
2 |
3 | import { createAI, createStreamableUI, getMutableAIState } from "ai/rsc";
4 | import OpenAI from "openai";
5 |
6 | import {
7 | spinner,
8 | BotCard,
9 | BotMessage,
10 | SystemMessage,
11 | Stock,
12 | Purchase,
13 | Stocks,
14 | Events,
15 | } from "@/components/llm-stocks";
16 | import { Pacman } from "@/components/pacman";
17 | import { Confetti } from "@/components/confetti";
18 |
19 | import {
20 | runAsyncFnWithoutBlocking,
21 | sleep,
22 | formatNumber,
23 | runOpenAICompletion,
24 | } from "@/lib/utils";
25 | import { z } from "zod";
26 | import { StockSkeleton } from "@/components/llm-stocks/stock-skeleton";
27 | import { EventsSkeleton } from "@/components/llm-stocks/events-skeleton";
28 | import { StocksSkeleton } from "@/components/llm-stocks/stocks-skeleton";
29 | import { messageRateLimit } from "@/lib/rate-limit";
30 | import { headers } from "next/headers";
31 |
32 | const openai = new OpenAI({
33 | apiKey: process.env.OPENAI_API_KEY || "",
34 | });
35 |
36 | async function confirmPurchase(symbol: string, price: number, amount: number) {
37 | "use server";
38 |
39 | const aiState = getMutableAIState();
40 |
41 | const purchasing = createStreamableUI(
42 |
43 | {spinner}
44 |
45 | Purchasing {amount} ${symbol}...
46 |
47 |
,
48 | );
49 |
50 | const systemMessage = createStreamableUI(null);
51 |
52 | runAsyncFnWithoutBlocking(async () => {
53 | // You can update the UI at any point.
54 | await sleep(1000);
55 |
56 | purchasing.update(
57 |
58 | {spinner}
59 |
60 | Purchasing {amount} ${symbol}... working on it...
61 |
62 |
,
63 | );
64 |
65 | await sleep(1000);
66 |
67 | purchasing.done(
68 |
69 |
70 | You have successfully purchased {amount} ${symbol}. Total cost:{" "}
71 | {formatNumber(amount * price)}
72 |
73 |
,
74 | );
75 |
76 | systemMessage.done(
77 |
78 | You have purchased {amount} shares of {symbol} at ${price}. Total cost ={" "}
79 | {formatNumber(amount * price)}.
80 | ,
81 | );
82 |
83 | aiState.done([
84 | ...aiState.get(),
85 | {
86 | role: "system",
87 | content: `[User has purchased ${amount} shares of ${symbol} at ${price}. Total cost = ${
88 | amount * price
89 | }]`,
90 | },
91 | ]);
92 | });
93 |
94 | return {
95 | purchasingUI: purchasing.value,
96 | newMessage: {
97 | id: Date.now(),
98 | display: systemMessage.value,
99 | },
100 | };
101 | }
102 |
103 | async function submitUserMessage(content: string) {
104 | "use server";
105 |
106 | const reply = createStreamableUI(
107 | {spinner} ,
108 | );
109 |
110 | const ip = headers().get("x-real-ip") ?? "local";
111 | const rl = await messageRateLimit.limit(ip);
112 |
113 | if (!rl.success) {
114 | reply.done(
115 | Rate limit exceeded. Try again in 15 minutes. ,
116 | );
117 | return {
118 | id: Date.now(),
119 | display: reply.value,
120 | };
121 | }
122 |
123 | const aiState = getMutableAIState();
124 | aiState.update([
125 | ...aiState.get(),
126 | {
127 | role: "user",
128 | content,
129 | },
130 | ]);
131 |
132 | const completion = runOpenAICompletion(openai, {
133 | model: "gpt-4-turbo-preview",
134 | stream: true,
135 | messages: [
136 | {
137 | role: "system",
138 | content: `\
139 | You are a stock trading conversation bot and you can help users buy stocks, step by step.
140 | You can let the user play pacman, as many times as they want.
141 | You can let the user throw confetti, as many times as they want, to celebrate.
142 | You and the user can discuss stock prices and the user can adjust the amount of stocks they want to buy, or place an order, in the UI.
143 |
144 | Messages inside [] means that it's a UI element or a user event. For example:
145 | - "[Price of AAPL = 100]" means that an interface of the stock price of AAPL is shown to the user.
146 | - "[User has changed the amount of AAPL to 10]" means that the user has changed the amount of AAPL to 10 in the UI.
147 |
148 | If the user requests playing pacman, call \`play_pacman\` to play pacman.
149 | If the user requests throwing confetti, call \`throw_confetti\` to throw confetti.
150 | If the user requests purchasing a stock, call \`show_stock_purchase_ui\` to show the purchase UI.
151 | If the user just wants the price, call \`show_stock_price\` to show the price.
152 | If you want to show trending stocks, call \`list_stocks\`.
153 | If you want to show events, call \`get_events\`.
154 | If the user wants to sell stock, or complete another impossible task, respond that you are a demo and cannot do that.
155 |
156 | Besides that, you can also chat with users and do some calculations if needed.`,
157 | },
158 | ...aiState.get().map((info: any) => ({
159 | role: info.role,
160 | content: info.content,
161 | name: info.name,
162 | })),
163 | ],
164 | functions: [
165 | {
166 | name: "play_pacman",
167 | description: "Play pacman with the user.",
168 | parameters: z.object({}),
169 | },
170 | {
171 | name: "throw_confetti",
172 | description: "Throw confetti to the user. Use this to celebrate.",
173 | parameters: z.object({}),
174 | },
175 | {
176 | name: "show_stock_price",
177 | description:
178 | "Get the current stock price of a given stock or currency. Use this to show the price to the user.",
179 | parameters: z.object({
180 | symbol: z
181 | .string()
182 | .describe(
183 | "The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.",
184 | ),
185 | price: z.number().describe("The price of the stock."),
186 | delta: z.number().describe("The change in price of the stock"),
187 | }),
188 | },
189 | {
190 | name: "show_stock_purchase_ui",
191 | description:
192 | "Show price and the UI to purchase a stock or currency. Use this if the user wants to purchase a stock or currency.",
193 | parameters: z.object({
194 | symbol: z
195 | .string()
196 | .describe(
197 | "The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.",
198 | ),
199 | price: z.number().describe("The price of the stock."),
200 | numberOfShares: z
201 | .number()
202 | .describe(
203 | "The **number of shares** for a stock or currency to purchase. Can be optional if the user did not specify it.",
204 | ),
205 | }),
206 | },
207 | {
208 | name: "list_stocks",
209 | description: "List three imaginary stocks that are trending.",
210 | parameters: z.object({
211 | stocks: z.array(
212 | z.object({
213 | symbol: z.string().describe("The symbol of the stock"),
214 | price: z.number().describe("The price of the stock"),
215 | delta: z.number().describe("The change in price of the stock"),
216 | }),
217 | ),
218 | }),
219 | },
220 | {
221 | name: "get_events",
222 | description:
223 | "List funny imaginary events between user highlighted dates that describe stock activity.",
224 | parameters: z.object({
225 | events: z.array(
226 | z.object({
227 | date: z
228 | .string()
229 | .describe("The date of the event, in ISO-8601 format"),
230 | headline: z.string().describe("The headline of the event"),
231 | description: z.string().describe("The description of the event"),
232 | }),
233 | ),
234 | }),
235 | },
236 | ],
237 | temperature: 0,
238 | });
239 |
240 | completion.onTextContent((content: string, isFinal: boolean) => {
241 | reply.update({content} );
242 | if (isFinal) {
243 | reply.done();
244 | aiState.done([...aiState.get(), { role: "assistant", content }]);
245 | }
246 | });
247 |
248 | completion.onFunctionCall("list_stocks", async ({ stocks }) => {
249 | reply.update(
250 |
251 |
252 | ,
253 | );
254 |
255 | await sleep(1000);
256 |
257 | reply.done(
258 |
259 |
260 | ,
261 | );
262 |
263 | aiState.done([
264 | ...aiState.get(),
265 | {
266 | role: "function",
267 | name: "list_stocks",
268 | content: JSON.stringify(stocks),
269 | },
270 | ]);
271 | });
272 |
273 | completion.onFunctionCall("get_events", async ({ events }) => {
274 | reply.update(
275 |
276 |
277 | ,
278 | );
279 |
280 | await sleep(1000);
281 |
282 | reply.done(
283 |
284 |
285 | ,
286 | );
287 |
288 | aiState.done([
289 | ...aiState.get(),
290 | {
291 | role: "function",
292 | name: "list_stocks",
293 | content: JSON.stringify(events),
294 | },
295 | ]);
296 | });
297 |
298 | completion.onFunctionCall("play_pacman", () => {
299 | reply.done(
300 |
301 |
302 | ,
303 | );
304 | aiState.done([
305 | ...aiState.get(),
306 | {
307 | role: "function",
308 | name: "play_pacman",
309 | content: `[User has requested to play pacman]`,
310 | },
311 | ]);
312 | });
313 |
314 | completion.onFunctionCall("throw_confetti", () => {
315 | reply.done(
316 |
317 |
318 | ,
319 | );
320 | aiState.done([
321 | ...aiState.get(),
322 | {
323 | role: "function",
324 | name: "throw_confetti",
325 | content: `[User has requested to throw confetti]`,
326 | },
327 | ]);
328 | });
329 |
330 | completion.onFunctionCall(
331 | "show_stock_price",
332 | async ({ symbol, price, delta }) => {
333 | reply.update(
334 |
335 |
336 | ,
337 | );
338 |
339 | await sleep(1000);
340 |
341 | reply.done(
342 |
343 |
344 | ,
345 | );
346 |
347 | aiState.done([
348 | ...aiState.get(),
349 | {
350 | role: "function",
351 | name: "show_stock_price",
352 | content: `[Price of ${symbol} = ${price}]`,
353 | },
354 | ]);
355 | },
356 | );
357 |
358 | completion.onFunctionCall(
359 | "show_stock_purchase_ui",
360 | ({ symbol, price, numberOfShares = 100 }) => {
361 | if (numberOfShares <= 0 || numberOfShares > 1000) {
362 | reply.done(Invalid amount );
363 | aiState.done([
364 | ...aiState.get(),
365 | {
366 | role: "function",
367 | name: "show_stock_purchase_ui",
368 | content: `[Invalid amount]`,
369 | },
370 | ]);
371 | return;
372 | }
373 |
374 | reply.done(
375 | <>
376 |
377 | Sure!{" "}
378 | {typeof numberOfShares === "number"
379 | ? `Click the button below to purchase ${numberOfShares} shares of $${symbol}:`
380 | : `How many $${symbol} would you like to purchase?`}
381 |
382 |
383 |
388 |
389 | >,
390 | );
391 | aiState.done([
392 | ...aiState.get(),
393 | {
394 | role: "function",
395 | name: "show_stock_purchase_ui",
396 | content: `[UI for purchasing ${numberOfShares} shares of ${symbol}. Current price = ${price}, total cost = ${
397 | numberOfShares * price
398 | }]`,
399 | },
400 | ]);
401 | },
402 | );
403 |
404 | return {
405 | id: Date.now(),
406 | display: reply.value,
407 | };
408 | }
409 |
410 | // Define necessary types and create the AI.
411 |
412 | const initialAIState: {
413 | role: "user" | "assistant" | "system" | "function";
414 | content: string;
415 | id?: string;
416 | name?: string;
417 | }[] = [];
418 |
419 | const initialUIState: {
420 | id: number;
421 | display: React.ReactNode;
422 | }[] = [];
423 |
424 | export const AI = createAI({
425 | actions: {
426 | submitUserMessage,
427 | confirmPurchase,
428 | },
429 | initialUIState,
430 | initialAIState,
431 | });
432 |
--------------------------------------------------------------------------------
/components/llm-stocks/stock.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useRef, useEffect, useId } from 'react';
4 | import { scaleLinear } from 'd3-scale';
5 | import { subMonths, format } from 'date-fns';
6 | import { useResizeObserver } from 'usehooks-ts';
7 | import { useAIState } from 'ai/rsc';
8 |
9 | import type { AI } from '../../app/action';
10 |
11 | export function Stock({ name = 'DOGE', price = 12.34, delta = 1 }) {
12 | const [history, setHistory] = useAIState();
13 | const [selectedDuration, setSelectedDuration] = useState('6M');
14 | const id = useId();
15 |
16 | const [priceAtTime, setPriceAtTime] = useState({
17 | time: '00:00',
18 | value: price.toFixed(2),
19 | x: 0,
20 | });
21 |
22 | const [startHighlight, setStartHighlight] = useState(0);
23 | const [endHighlight, setEndHighlight] = useState(0);
24 |
25 | const chartRef = useRef(null);
26 | const { width = 0 } = useResizeObserver({
27 | ref: chartRef,
28 | box: 'border-box',
29 | });
30 |
31 | const xToDate = scaleLinear(
32 | [0, width],
33 | [subMonths(new Date(), 6), new Date()],
34 | );
35 | const xToValue = scaleLinear(
36 | [0, width],
37 | [price - price / 2, price + price / 2],
38 | );
39 |
40 | useEffect(() => {
41 | if (startHighlight && endHighlight) {
42 | const message = {
43 | id,
44 | role: 'system' as const,
45 | content: `[User has highlighted dates between between ${format(
46 | xToDate(startHighlight),
47 | 'd LLL',
48 | )} and ${format(xToDate(endHighlight), 'd LLL, yyyy')}`,
49 | };
50 |
51 | if (history[history.length - 1]?.id === id) {
52 | setHistory([...history.slice(0, -1), message]);
53 | } else {
54 | setHistory([...history, message]);
55 | }
56 | }
57 | }, [startHighlight, endHighlight, history, id, setHistory, xToDate]);
58 |
59 | return (
60 |
61 |
62 | {`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${
63 | delta > 0 ? '↑' : '↓'
64 | }`}
65 |
66 |
{name}
67 |
${price}
68 |
69 | Closed: Feb 27, 4:59 PM EST
70 |
71 |
72 |
{
75 | if (chartRef.current) {
76 | const { clientX } = event;
77 | const { left } = chartRef.current.getBoundingClientRect();
78 |
79 | setStartHighlight(clientX - left);
80 | setEndHighlight(0);
81 |
82 | setPriceAtTime({
83 | time: format(xToDate(clientX), 'dd LLL yy'),
84 | value: xToValue(clientX).toFixed(2),
85 | x: clientX - left,
86 | });
87 | }
88 | }}
89 | onPointerUp={event => {
90 | if (chartRef.current) {
91 | const { clientX } = event;
92 | const { left } = chartRef.current.getBoundingClientRect();
93 |
94 | setEndHighlight(clientX - left);
95 | }
96 | }}
97 | onPointerMove={event => {
98 | if (chartRef.current) {
99 | const { clientX } = event;
100 | const { left } = chartRef.current.getBoundingClientRect();
101 |
102 | setPriceAtTime({
103 | time: format(xToDate(clientX), 'dd LLL yy'),
104 | value: xToValue(clientX).toFixed(2),
105 | x: clientX - left,
106 | });
107 | }
108 | }}
109 | onPointerLeave={() => {
110 | setPriceAtTime({
111 | time: '00:00',
112 | value: price.toFixed(2),
113 | x: 0,
114 | });
115 | }}
116 | ref={chartRef}
117 | >
118 | {priceAtTime.x > 0 ? (
119 |
126 |
${priceAtTime.value}
127 |
128 | {priceAtTime.time}
129 |
130 |
131 | ) : null}
132 |
133 | {startHighlight ? (
134 |
144 | ) : null}
145 |
146 |
153 |
154 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
191 |
197 |
198 |
199 |
200 | );
201 | }
202 |
--------------------------------------------------------------------------------
/components/ui/icons.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | function IconAI({ className, ...props }: React.ComponentProps<'svg'>) {
8 | return (
9 |
17 |
18 |
19 | );
20 | }
21 |
22 | function IconVercel({ className, ...props }: React.ComponentProps<'svg'>) {
23 | return (
24 |
31 |
35 |
36 | );
37 | }
38 |
39 | function IconGitHub({ className, ...props }: React.ComponentProps<'svg'>) {
40 | return (
41 |
49 | GitHub
50 |
51 |
52 | );
53 | }
54 |
55 | function IconSeparator({ className, ...props }: React.ComponentProps<'svg'>) {
56 | return (
57 |
69 |
70 |
71 | );
72 | }
73 |
74 | function IconArrowRight({ className, ...props }: React.ComponentProps<'svg'>) {
75 | return (
76 |
83 |
84 |
85 | );
86 | }
87 |
88 | function IconUser({ className, ...props }: React.ComponentProps<'svg'>) {
89 | return (
90 |
97 |
98 |
99 | );
100 | }
101 |
102 | function IconPlus({ className, ...props }: React.ComponentProps<'svg'>) {
103 | return (
104 |
111 |
112 |
113 | );
114 | }
115 |
116 | function IconArrowElbow({ className, ...props }: React.ComponentProps<'svg'>) {
117 | return (
118 |
125 |
126 |
127 | );
128 | }
129 |
130 | function IconSpinner({ className, ...props }: React.ComponentProps<'svg'>) {
131 | return (
132 |
139 |
140 |
141 | );
142 | }
143 |
144 | function IconMessage({ className, ...props }: React.ComponentProps<'svg'>) {
145 | return (
146 |
153 |
154 |
155 | );
156 | }
157 |
158 | function IconCopy({ className, ...props }: React.ComponentProps<'svg'>) {
159 | return (
160 |
167 |
168 |
169 | );
170 | }
171 |
172 | function IconCheck({ className, ...props }: React.ComponentProps<'svg'>) {
173 | return (
174 |
181 |
182 |
183 | );
184 | }
185 |
186 | function IconClose({ className, ...props }: React.ComponentProps<'svg'>) {
187 | return (
188 |
195 |
196 |
197 | );
198 | }
199 |
200 | function IconShare({ className, ...props }: React.ComponentProps<'svg'>) {
201 | return (
202 |
209 |
210 |
211 | );
212 | }
213 |
214 | function IconUsers({ className, ...props }: React.ComponentProps<'svg'>) {
215 | return (
216 |
223 |
224 |
225 | );
226 | }
227 |
228 | function IconExternalLink({
229 | className,
230 | ...props
231 | }: React.ComponentProps<'svg'>) {
232 | return (
233 |
240 |
241 |
242 | );
243 | }
244 |
245 | function IconChevronUpDown({
246 | className,
247 | ...props
248 | }: React.ComponentProps<'svg'>) {
249 | return (
250 |
257 |
258 |
259 | );
260 | }
261 |
262 | function IconSparkles({ className, ...props }: React.ComponentProps<'svg'>) {
263 | return (
264 |
271 |
275 |
276 | );
277 | }
278 |
279 | export {
280 | IconAI,
281 | IconVercel,
282 | IconGitHub,
283 | IconSeparator,
284 | IconUser,
285 | IconPlus,
286 | IconArrowRight,
287 | IconArrowElbow,
288 | IconSpinner,
289 | IconMessage,
290 | IconCopy,
291 | IconCheck,
292 | IconClose,
293 | IconShare,
294 | IconUsers,
295 | IconExternalLink,
296 | IconChevronUpDown,
297 | IconSparkles,
298 | };
299 |
--------------------------------------------------------------------------------