Skip to content

Commit

Permalink
Add AI Wizard on market page (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
Diogomartf authored Feb 5, 2025
1 parent 6adeb06 commit 58af0dd
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
7 changes: 7 additions & 0 deletions analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
242 changes: 242 additions & 0 deletions app/(main)/markets/AiChat.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<Pick<Query, 'fixedProductMarketMaker'>>({
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 (
<Dialog.Root modal onOpenChange={isOpen => setOpen(isOpen)}>
<div className="fixed bottom-10 flex w-full items-center justify-end px-2 md:px-6">
<div className="flex flex-col items-end">
<Dialog.Portal>
<Dialog.Content className="fixed bottom-28 right-0 w-full origin-bottom-right rounded-16 border border-outline-base-em bg-surface-surface-0 shadow-2 data-[state=open]:animate-grow md:right-4 md:w-[420px]">
<Dialog.Description hidden={true}>
Presagio chatbot window
</Dialog.Description>
<div className="border-b border-outline-low-em p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Image alt="ai stars" width={14} height={14} src={aiStarsSvg} />
<Dialog.DialogTitle className="font-semibold">
Ask Wizard
</Dialog.DialogTitle>
<Tag colorScheme="info">Experimental</Tag>
</div>
<Dialog.Close asChild>
<IconButton
size="sm"
className="rotate-90"
variant="ghost"
name="arrow-double-right"
/>
</Dialog.Close>
</div>
</div>
<ScrollArea className="h-[460px] md:h-[536px]">
<div className="space-y-4 px-4 pb-32 pt-4">
{messages.map((message, index) => (
<Message key={index} role={message.role}>
{message.content}
</Message>
))}
{noAssistantMessages && (
<div className="space-y-1 rounded-20 bg-outline-primary-base-em px-4 py-2">
<div className="flex items-center space-x-2">
<p>{waitingMessges.at(randomWaitingMessageIndex)}</p>
<LoadingDots />
</div>
</div>
)}
{hasError && (
<Message role="assistant">Oops. Something went wrong.</Message>
)}
</div>
</ScrollArea>
<div className="absolute bottom-0 w-full rounded-b-16 border-t border-surface-surface-1 bg-surface-surface-0 px-4 pb-4 pt-2">
<div className="flex items-center justify-center">
<a
onClick={() => {
trackEvent(FA_EVENTS.MARKET.AI_CHAT.GET_BETA_ACCESS);
}}
href={MARKETING_LINK}
className="mb-1 hover:underline"
target="_blank"
>
Get beta access to Presagio AI ✨
</a>
<Icon name="arrow-top-right" size={12} />
</div>
<Input
placeholder="Ask anything. Available soon."
className="w-full"
disabled
/>
<p className="w-full text-center text-xs text-text-low-em">
AI-generated, for reference only.
</p>
</div>
</Dialog.Content>
</Dialog.Portal>

<Dialog.Trigger asChild>
<button
onClick={() => {
trackEvent(FA_EVENTS.MARKET.AI_CHAT.OPEN_GENEREAL);
trackEvent(FA_EVENTS.MARKET.AI_CHAT.OPEN(id));
}}
className={twMerge(
'flex size-16 items-center justify-center rounded-100 bg-transparent shadow-1 outline-outline-primary-low-em backdrop-blur-sm transition-colors duration-700 hover:bg-outline-primary-base-em focus:bg-outline-primary-base-em',
'data-[state=open]:bg-outline-primary-base-em data-[state=open]:shadow-2'
)}
>
<Image alt="ai wizard" width={42} height={42} src={wizardSvg} />
</button>
</Dialog.Trigger>
</div>
</div>
</Dialog.Root>
);
};

interface MessageProps extends PropsWithChildren {
role: string;
}

const Message = ({ children, role }: MessageProps) => {
const isSystem = role === 'assistant';
return (
<MarkdownRenderer
className={cx(
'rounded-20 px-4 py-2',
isSystem ? 'bg-outline-primary-base-em' : 'bg-surface-surface-1'
)}
>
{children}
</MarkdownRenderer>
);
};

const LoadingDots = () => {
return (
<div className="flex items-center justify-center space-x-0.5" aria-label="Loading">
<div className="animate-loading-dot h-1 w-1 rounded-100 bg-surface-primary-main"></div>
<div className="animate-loading-dot h-1 w-1 rounded-100 bg-surface-primary-main [animation-delay:0.2s]"></div>
<div className="animate-loading-dot h-1 w-1 rounded-100 bg-surface-primary-main [animation-delay:0.4s]"></div>
</div>
);
};

export default AiChat;
2 changes: 2 additions & 0 deletions app/(main)/markets/MarketDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -214,6 +215,7 @@ export const MarketDetails = ({ id }: MarketDetailsProps) => {
</div>
</div>
<EmbedMarketModal id={id} />
<AiChat id={id} />
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './toasts';
export * from './Spinner';
export * from './TxButton';
export * from './StatsCard';
export * from './markdown-renderer';
48 changes: 48 additions & 0 deletions app/components/markdown-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <h1 className={markdownStyles.heading.h1}>{children}</h1>,
h2: ({ children }) => <h2 className={markdownStyles.heading.h2}>{children}</h2>,
h3: ({ children }) => <h3 className={markdownStyles.heading.h3}>{children}</h3>,
p: ({ children }) => <p className={markdownStyles.paragraph}>{children}</p>,
ul: ({ children }) => <ul className={markdownStyles.list}>{children}</ul>,
li: ({ children }) => <li className={markdownStyles.listItem}>{children}</li>,
};

export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
children,
className = '',
}) => {
return (
<div className={className}>
<ReactMarkdown components={components}>{children?.toString()}</ReactMarkdown>
</div>
);
};
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions public/ai-stars.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 58af0dd

Please sign in to comment.