diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c8459f --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_PRESAGIO_CHAT_API_URL= + +NEXT_PUBLIC_SUBGRAPH_API_KEY= +NEXT_PUBLIC_DUNE_API_KEY= + +NEXT_PUBLIC_X_URL= +NEXT_PUBLIC_DISCORD_URL= + + +NEXT_PUBLIC_FATHOM_KEY= \ No newline at end of file diff --git a/analytics/index.ts b/analytics/index.ts index f2109aa..04c6756 100644 --- a/analytics/index.ts +++ b/analytics/index.ts @@ -28,6 +28,13 @@ export const FA_EVENTS = { ROUTE_FAILED: 'click/lifi-widget/route-failed', ROUTE_SUCCESS: 'click/lifi-widget/route-success', }, + MARKET: { + AI_CHAT: { + OPEN_GENEREAL: 'click/market/ai-chat/open-general', + OPEN: (id: string) => `click/market/${id}/ai-chat/open`, + GET_BETA_ACCESS: 'click/market/ai-chat/get-beta-access', + }, + }, MARKETS: { CATEGORY: (category: string) => `click/markets/category-${category}`, DETAILS: { diff --git a/app/(main)/markets/AiChat.tsx b/app/(main)/markets/AiChat.tsx new file mode 100644 index 0000000..4f58a30 --- /dev/null +++ b/app/(main)/markets/AiChat.tsx @@ -0,0 +1,242 @@ +'use client'; + +import Image from 'next/image'; +import { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { MarkdownRenderer, ScrollArea } from '@/app/components'; +import aiStarsSvg from '@/public/ai-stars.svg'; +import wizardSvg from '@/public/pixel-wizard.svg'; +import { cx } from 'class-variance-authority'; +import { Address } from 'viem'; +import { Icon, IconButton, Input, Tag } from '@swapr/ui'; +import { twMerge } from 'tailwind-merge'; +import { FA_EVENTS } from '@/analytics'; +import { trackEvent } from 'fathom-client'; +import { useQuery } from '@tanstack/react-query'; +import { useChat } from 'ai/react'; +import { getMarket, Query } from '@/queries/omen'; + +interface AiChatProps { + id: Address; +} + +const PRESAGIO_CHAT_API_URL = process.env.NEXT_PUBLIC_PRESAGIO_CHAT_API_URL!; + +const MARKETING_LINK = + 'https://swpr.notion.site/Presagio-AI-Predictor-Chatbot-Beta-Launch-f4ccdfa867e949d3badf10705b7c90aa'; + +type Role = 'user' | 'assitant'; + +interface Message { + content: string; + role: Role; +} + +const waitingMessges = [ + 'Searching my old books for answers…', + 'The oracle whisper… Not yet clear.', + 'Consulting the tomes and the stars… Patience, seeker.', + 'The ancient texts and omens are alignin… I see something!', + 'I feel it in my beard… The answer nears!', + 'Gathering acient knowledge… Coming is the answer.', + 'The oracle stirs, the pages turn… soon, the answer.', + 'I will show you the answer! If my internet allows…', +]; + +const fetchMessages = async (id: Address) => { + const response = await fetch( + `${PRESAGIO_CHAT_API_URL}/api/market-chat?market_id=${id}` + ); + if (!response.ok) { + throw new Error('Presagio AI response was not ok'); + } + return response.json(); +}; + +export const AiChat = ({ id }: AiChatProps) => { + const [isOpen, setOpen] = useState(false); + const [messageSent, setMessageSent] = useState(false); + const scrollAreaRef = useRef(null); + + const randomWaitingMessageIndex = Math.round( + Math.random() * (waitingMessges.length - 1) + ); + + const { + data, + error: messagesError, + isFetched: isFetchedMessages, + } = useQuery({ + queryKey: ['fetchMessages', id], + queryFn: () => fetchMessages(id), + }); + const { data: market } = useQuery>({ + queryKey: ['getMarket', id], + queryFn: async () => getMarket({ id }), + staleTime: Infinity, + }); + + const { + messages, + error: chatError, + append, + } = useChat({ + id, + body: { + marketId: id, + }, + api: `${PRESAGIO_CHAT_API_URL}/api/market-chat`, + initialMessages: data, + }); + + const title = market?.fixedProductMarketMaker?.title; + + useEffect(() => { + const controller = new AbortController(); + const signal = controller.signal; + + if (signal.aborted) return; + + if (isFetchedMessages && data?.length === 0 && title && isOpen && !messageSent) { + setMessageSent(true); + append({ role: 'user', content: title }); + } + + return () => controller.abort(); + }, [append, data?.length, isFetchedMessages, messageSent, isOpen, title]); + + useEffect(() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; + } + }, [scrollAreaRef]); + + const assistantMessages = messages.filter(({ role }) => role === 'assistant'); + const noAssistantMessages = + messages.length === 1 && + (assistantMessages.length === 0 || assistantMessages?.at(0)?.content.length === 0); + const hasError = messagesError || chatError; + + return ( + setOpen(isOpen)}> +
+
+ + + +
+
+
+ ai stars + + Ask Wizard + + Experimental +
+ + + +
+
+ +
+ {messages.map((message, index) => ( + + {message.content} + + ))} + {noAssistantMessages && ( +
+
+

{waitingMessges.at(randomWaitingMessageIndex)}

+ +
+
+ )} + {hasError && ( + Oops. Something went wrong. + )} +
+
+ +
+
+ + + + +
+
+
+ ); +}; + +interface MessageProps extends PropsWithChildren { + role: string; +} + +const Message = ({ children, role }: MessageProps) => { + const isSystem = role === 'assistant'; + return ( + + {children} + + ); +}; + +const LoadingDots = () => { + return ( +
+
+
+
+
+ ); +}; + +export default AiChat; diff --git a/app/(main)/markets/MarketDetails.tsx b/app/(main)/markets/MarketDetails.tsx index f962b10..1a0d6d6 100644 --- a/app/(main)/markets/MarketDetails.tsx +++ b/app/(main)/markets/MarketDetails.tsx @@ -30,6 +30,7 @@ import { MarketNotFound } from './MarketNotFound'; import { Liquidity } from './Liquidity'; import { UserLiquidity } from './UserLiquidity'; import { getQuestionValidity } from '@/queries/gnosis-ai'; +import AiChat from './AiChat'; interface MarketDetailsProps { id: Address; @@ -214,6 +215,7 @@ export const MarketDetails = ({ id }: MarketDetailsProps) => { + ); }; diff --git a/app/components/index.ts b/app/components/index.ts index df55683..985bd2d 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -22,3 +22,4 @@ export * from './toasts'; export * from './Spinner'; export * from './TxButton'; export * from './StatsCard'; +export * from './markdown-renderer'; diff --git a/app/components/markdown-renderer.tsx b/app/components/markdown-renderer.tsx new file mode 100644 index 0000000..25d21c2 --- /dev/null +++ b/app/components/markdown-renderer.tsx @@ -0,0 +1,48 @@ +import { PropsWithChildren } from 'react'; +import ReactMarkdown, { Components } from 'react-markdown'; + +interface MarkdownRendererProps extends PropsWithChildren { + className?: string; +} + +interface MarkdownStyles { + heading: { + h1: string; + h2: string; + h3: string; + }; + paragraph: string; + list: string; + listItem: string; +} + +const markdownStyles: MarkdownStyles = { + heading: { + h1: 'text-4xl font-bold my-4', + h2: 'text-3xl font-bold my-3', + h3: 'text-2xl font-bold my-2', + }, + paragraph: 'my-2', + list: 'list-disc list-inside my-2', + listItem: 'ml-4', +}; + +const components: Components = { + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , +}; + +export const MarkdownRenderer: React.FC = ({ + children, + className = '', +}) => { + return ( +
    + {children?.toString()} +
    + ); +}; diff --git a/bun.lockb b/bun.lockb index 3a1b9ea..10ff652 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0da3601..301d2c0 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "@duneanalytics/client-sdk": "0.2.4", "@fvictorio/newton-raphson-method": "^1.0.5", "@lifi/widget": "^3.7.0", + "@radix-ui/react-dialog": "^1.1.5", "@radix-ui/react-scroll-area": "^1.0.5", "@swapr/ui": "^0.1.42", "@tanstack/react-query": "^5.36.2", "@types/react-syntax-highlighter": "^15.5.13", + "ai": "^4.1.11", "big.js": "^6.2.1", "bs58": "^6.0.0", "class-variance-authority": "^0.7.0", @@ -33,6 +35,7 @@ "react": "^18", "react-device-detect": "^2.2.3", "react-dom": "^18", + "react-markdown": "^9.0.3", "react-syntax-highlighter": "^15.5.0", "recharts": "^2.12.7", "tailwind-merge": "^2.5.4", diff --git a/public/ai-stars.svg b/public/ai-stars.svg new file mode 100644 index 0000000..554a97b --- /dev/null +++ b/public/ai-stars.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/pixel-wizard.svg b/public/pixel-wizard.svg new file mode 100644 index 0000000..4007577 --- /dev/null +++ b/public/pixel-wizard.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tailwind.config.ts b/tailwind.config.ts index 10e7dda..5e63969 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -11,13 +11,23 @@ const config: Config = { theme: { extend: { keyframes: { + grow: { + '0%': { transform: 'scale(0.5)', opacity: '0' }, + '100%': { transform: 'scale(1)', opacity: '1' }, + }, cityFlip: { '0%, 100%': { transform: 'translateY(0)', opacity: '1' }, '50%': { transform: 'translateY(-10px)', opacity: '0' }, }, + 'loading-dot': { + '0%, 100%': { opacity: '0.2' }, + '20%': { opacity: '1' }, + }, }, animation: { + 'loading-dot': 'loading-dot 1.4s infinite ease-in-out', 'city-flip': 'cityFlip 200ms ease-in-out', + grow: 'grow 0.2s ease-out', }, }, },