From 8fbeb790e3f484908e0661d0d5f04379fcee4e07 Mon Sep 17 00:00:00 2001 From: jacob-moore-cb Date: Thu, 7 Mar 2024 13:20:35 -0800 Subject: [PATCH] Add GPT Chat to Base Docs (#323) * Install fetch event source to handle SSEs * Add docs chatbot modal UI. * Update Modal and Icon components. * Add no scroll class. * Add DocChat component. * Installed fetch event source. * Add light theme styles. Clean up code. * Remove unneeded React import. * Adjust copy as deployment test change. * Undo test changes. * Add Mendable URLs to CSP. * Add markdown rendering support for chatbot responses. * Yarn lock update. * Adjust modal layout spacing. * Fix chatbot modal styling for mobile screens. * Add UI for rating bot responses. * Temporarily disable response rating feature. * Add server API endpoint for processing Base GPT response rating requests. * Update csp for new API version. * Add CCA events for GPT interactions. Fix id data types. * Swap in prod client-side API keys. * Use session storage for conversation data. * Swap in prod client-side API keys. * Remove unneeded log. * Simplify state updates. * Replace dangerouslySetInnerHTML with useRef innerHTML. * Updated yarn.lock * Swap in test API key for dev. * Fix typo. * Swap in prod client-side key. * Temporarily remove sources. Adjust scroll behavior. Adjust modal close handler. Update API keys. --- apps/base-docs/.env.example | 1 + apps/base-docs/.gitignore | 1 + apps/base-docs/docs/overview.md | 4 +- apps/base-docs/docusaurus.d.ts | 5 +- apps/base-docs/package.json | 6 + apps/base-docs/server.js | 30 ++ .../src/components/DocChat/ChatMessage.tsx | 92 ++++ .../src/components/DocChat/ChatModal.tsx | 210 +++++++++ .../components/DocChat/FloatingChatButton.tsx | 42 ++ .../components/DocChat/ResponseFeedback.tsx | 109 +++++ .../src/components/DocChat/ResponseSource.tsx | 38 ++ .../src/components/DocChat/docChat.ts | 255 +++++++++++ .../src/components/DocChat/index.tsx | 18 + .../src/components/DocChat/styles.module.css | 404 ++++++++++++++++++ .../src/components/DocFeedback/index.tsx | 3 +- apps/base-docs/src/components/Icon/index.tsx | 108 ++++- apps/base-docs/src/components/Modal/index.tsx | 15 +- .../src/components/Modal/styles.module.css | 21 +- apps/base-docs/src/css/custom.css | 5 + .../src/theme/DocItem/Content/index.js | 10 +- .../src/utils/docusaurusCustomFields.ts | 11 + apps/base-docs/src/utils/initCCA.ts | 3 +- apps/base-docs/src/utils/marked.ts | 15 + yarn.lock | 81 +++- 24 files changed, 1469 insertions(+), 18 deletions(-) create mode 100644 apps/base-docs/.env.example create mode 100644 apps/base-docs/.gitignore create mode 100644 apps/base-docs/src/components/DocChat/ChatMessage.tsx create mode 100644 apps/base-docs/src/components/DocChat/ChatModal.tsx create mode 100644 apps/base-docs/src/components/DocChat/FloatingChatButton.tsx create mode 100644 apps/base-docs/src/components/DocChat/ResponseFeedback.tsx create mode 100644 apps/base-docs/src/components/DocChat/ResponseSource.tsx create mode 100644 apps/base-docs/src/components/DocChat/docChat.ts create mode 100644 apps/base-docs/src/components/DocChat/index.tsx create mode 100644 apps/base-docs/src/components/DocChat/styles.module.css create mode 100644 apps/base-docs/src/utils/docusaurusCustomFields.ts create mode 100644 apps/base-docs/src/utils/marked.ts diff --git a/apps/base-docs/.env.example b/apps/base-docs/.env.example new file mode 100644 index 0000000000..7043bf78a8 --- /dev/null +++ b/apps/base-docs/.env.example @@ -0,0 +1 @@ +MENDABLE_SERVER_API_KEY= \ No newline at end of file diff --git a/apps/base-docs/.gitignore b/apps/base-docs/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/apps/base-docs/.gitignore @@ -0,0 +1 @@ +.env diff --git a/apps/base-docs/docs/overview.md b/apps/base-docs/docs/overview.md index 0d24963174..5ef6f5567b 100644 --- a/apps/base-docs/docs/overview.md +++ b/apps/base-docs/docs/overview.md @@ -35,8 +35,8 @@ Get the EVM environment at a fraction of the cost. Get early access to Ethereum ### Open source -Base is built on the MIT-licensed [OP Stack](https://stack.optimism.io/), in collaboration with Optimism. We’re joining as the second Core Dev team working on the OP Stack to ensure it’s a public good available to everyone. +Base is built on the MIT-licensed [OP Stack](https://stack.optimism.io/), in collaboration with Optimism. We're joining as the second Core Dev team working on the OP Stack to ensure it’s a public good available to everyone. ### Scaled by Coinbase -Base is an easy way for decentralized apps to leverage Coinbase’s products and distribution. Seamless Coinbase integrations, easy fiat onramps, and access to millions of verified users in the Coinbase ecosystem. +Base is an easy way for decentralized apps to leverage Coinbase's products and distribution. Seamless Coinbase integrations, easy fiat onramps, and access to millions of verified users in the Coinbase ecosystem. diff --git a/apps/base-docs/docusaurus.d.ts b/apps/base-docs/docusaurus.d.ts index 5435433b3e..44742dd2d5 100644 --- a/apps/base-docs/docusaurus.d.ts +++ b/apps/base-docs/docusaurus.d.ts @@ -52,11 +52,14 @@ enum AnalyticsEventImportance { type CCAEventData = { // Standard Attributes action: ActionType; - componentType: ComponentType; + component_type: ComponentType; // Custom Attributes doc_helpful?: boolean; doc_feedback_reason?: string | null; page_path?: string; + conversation_id?: number; + message_id?: number; + response_helpful?: boolean; }; export type LogEvent = ( diff --git a/apps/base-docs/package.json b/apps/base-docs/package.json index 554bdf2fd7..85a69ae99b 100644 --- a/apps/base-docs/package.json +++ b/apps/base-docs/package.json @@ -21,12 +21,18 @@ "@docusaurus/core": "2.4.1", "@docusaurus/preset-classic": "2.4.1", "@mdx-js/react": "^1.6.22", + "@microsoft/fetch-event-source": "^2.0.1", "@rainbow-me/rainbowkit": "^1.0.4", + "@types/dompurify": "^3.0.5", + "body-parser": "^1.20.2", "docusaurus-node-polyfills": "^1.0.0", + "dompurify": "^3.0.8", "dotenv": "^16.3.1", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "lodash": "^4.17.21", + "marked": "^11.1.1", + "node-fetch": "2", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.1.3", diff --git a/apps/base-docs/server.js b/apps/base-docs/server.js index b157c8bea7..46b50136b2 100644 --- a/apps/base-docs/server.js +++ b/apps/base-docs/server.js @@ -3,6 +3,11 @@ const path = require('path'); const basicAuth = require('express-basic-auth'); const fs = require('fs'); const notFound = require('./404.js'); +const bodyParser = require('body-parser'); +const fetch = require('node-fetch'); +const dotenv = require('dotenv'); + +dotenv.config(); const unless = function (path, middleware) { return function (req, res, next) { @@ -18,10 +23,32 @@ const app = express(); app.use(express.static('static')); +app.use(bodyParser.json()); + app.get('/api/_health', (_, res) => { res.sendStatus(200); }); +app.post('/api/rateMessage', (req, res) => { + const { message_id, rating_value } = req.body; + + const data = { + api_key: process.env.MENDABLE_SERVER_API_KEY, + message_id, + rating_value, + }; + + fetch('https://api.mendable.ai/v1/rateMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + .then((response) => res.json(response)) + .catch((error) => res.status(400)); +}); + app.get('/base-camp', (req, res) => { res.redirect('base-camp/docs/welcome'); }); @@ -73,6 +100,9 @@ const contentSecurityPolicy = { 'https://cca-lite.coinbase.com', // CCA Lite 'https://*.algolia.net', // Algolia Search 'https://*.algolianet.com', // Algolia Search + 'https://api.mendable.ai/v1/newConversation', // Mendable API + 'https://api.mendable.ai/v1/mendableChat', // Mendable API + 'https://api.mendable.ai/v1/rateMessage', // Mendable API ], 'frame-src': ["'self'", 'https://player.vimeo.com', 'https://verify.walletconnect.org'], }; diff --git a/apps/base-docs/src/components/DocChat/ChatMessage.tsx b/apps/base-docs/src/components/DocChat/ChatMessage.tsx new file mode 100644 index 0000000000..e18199ba71 --- /dev/null +++ b/apps/base-docs/src/components/DocChat/ChatMessage.tsx @@ -0,0 +1,92 @@ +import React, { useRef, useLayoutEffect } from 'react'; +import { parseMarkdown } from '../../utils/marked'; + +import Icon from '../Icon'; +import ResponseFeedback from './ResponseFeedback'; +// import ResponseSource from './ResponseSource'; + +import styles from './styles.module.css'; + +import { ConversationMessage } from './docChat'; + +type ChatMessageProps = { + index: number; + type: ConversationMessage['type']; + content: ConversationMessage['content']; + sources?: ConversationMessage['sources']; + messageId?: number; + conversationId: number; + conversation: ConversationMessage[]; + setConversation: ( + conversation: (prevState: ConversationMessage[]) => ConversationMessage[], + ) => void; +}; + +export default function ChatMessage({ + index, + messageId, + conversationId, + conversation, + setConversation, + type, + content, + sources, +}: ChatMessageProps) { + const responseContentRef = useRef(null); + + useLayoutEffect(() => { + if (responseContentRef.current) { + responseContentRef.current.innerHTML = parseMarkdown(content); + } + }, [content]); + + return ( +
+
+ {type === 'prompt' && content !== '' && ( + <> +
+ +
+
{content}
+ + )} + + {type === 'response' && content !== '' && ( + <> +
+ +
+
+ + )} +
+ + {sources && sources.length > 0 && ( + <> + + + {/* Source data provided by the Mendable API needs more tuning but will be supported in a future release */} + {/*
Verified Sources:
+
+ {sources.map((source, i) => ( + + ))} +
*/} + + )} +
+ ); +} diff --git a/apps/base-docs/src/components/DocChat/ChatModal.tsx b/apps/base-docs/src/components/DocChat/ChatModal.tsx new file mode 100644 index 0000000000..e1baefc462 --- /dev/null +++ b/apps/base-docs/src/components/DocChat/ChatModal.tsx @@ -0,0 +1,210 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import Modal from '../Modal'; +import ChatMessage from './ChatMessage'; +import Icon from '../Icon'; + +import styles from './styles.module.css'; + +import { + ConversationMessage, + ChatHistoryMessage, + getConversationId, + setSessionConversation, + getSessionConversation, + streamPromptResponse, + controller, +} from './docChat'; + +type ChatModalProps = { + visible: boolean; + setVisible: React.Dispatch>; +}; + +export default function ChatModal({ visible, setVisible }: ChatModalProps) { + const [conversationId, setConversationId] = useState(0); + const [conversation, setConversation] = useState([]); + const [chatHistory, setChatHistory] = useState([]); + const [prompt, setPrompt] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [isAutoScrolling, setIsAutoScrolling] = useState(true); + + const conversationContainerRef = useRef(null); + const currentMessage: ConversationMessage = conversation[conversation.length - 1]; + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => setPrompt(e.target.value), + [prompt], + ); + + const handleSubmit = useCallback( + (e: React.SyntheticEvent) => { + e.preventDefault(); + + if (!conversationId || !prompt || isLoading || isGenerating) return; + + setIsLoading(true); + setIsAutoScrolling(true); + + setChatHistory((prevState: ChatHistoryMessage[]) => [...prevState, { prompt, response: '' }]); + + setConversation((prevState: ConversationMessage[]) => [ + ...prevState, + { type: 'prompt', content: prompt }, + { type: 'response', content: '' }, + ]); + + streamPromptResponse( + conversationId, + prompt, + setIsLoading, + isGenerating, + setIsGenerating, + chatHistory, + setChatHistory, + setConversation, + ).catch((err) => console.error(err)); + + setPrompt(''); + }, + [conversationId, prompt, isLoading, isGenerating, chatHistory, conversation], + ); + + const handleReset = useCallback(() => { + setPrompt(''); + setChatHistory([]); + setConversation([]); + setSessionConversation([]); + setIsLoading(false); + setIsGenerating(false); + setIsAutoScrolling(true); + if (controller) controller.abort(); + }, [controller]); + + const handleModalClose = useCallback(() => { + setVisible(false); + + // perform soft reset when modal is closed + setPrompt(''); + setIsLoading(false); + setIsGenerating(false); + setIsAutoScrolling(true); + if (controller) controller.abort(); + }, [controller]); + + const handleStopGenerating = useCallback(() => { + setIsGenerating(false); + if (controller) controller.abort(); + }, [controller]); + + const handleConversationScroll = useCallback(() => { + // When user scrolls conversation, stop programmatically scrolling to bottom + if (isAutoScrolling) setIsAutoScrolling(false); + }, [isAutoScrolling]); + + useEffect(() => { + // Only get conversation ID if modal is opened + if (visible) { + getConversationId() + .then((id) => { + setConversationId(id); + setConversation(getSessionConversation()); + }) + .catch((err) => console.error(err)); + } + }, [visible]); + + useEffect(() => { + if (isAutoScrolling) { + conversationContainerRef.current?.scrollBy(0, conversationContainerRef.current.scrollHeight); + } + // Scroll to bottom of conversation container when: + // - Message is added to conversation + // - Response content is generated + // - Response sources are added + }, [conversation.length, currentMessage?.content, currentMessage?.sources]); + + return ( + +
+ + +
+ + + {conversation.map((message, i) => ( + +
+ + + ))} + + {isLoading && ( +
+ + + + Searching... +
+ )} + + {isGenerating && ( + + )} +
+
+ +
+
+ + +
+ {isLoading || isGenerating ? ( + + + + ) : ( + + )} +
+
+ +
+ This tool uses AI to generate results. Please do not enter any sensitive information. +
+
+ + ); +} diff --git a/apps/base-docs/src/components/DocChat/FloatingChatButton.tsx b/apps/base-docs/src/components/DocChat/FloatingChatButton.tsx new file mode 100644 index 0000000000..8e65c6f4fe --- /dev/null +++ b/apps/base-docs/src/components/DocChat/FloatingChatButton.tsx @@ -0,0 +1,42 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import Icon from '../Icon'; + +import styles from './styles.module.css'; + +type FloatingChatButtonProps = { + onClick: () => void; +}; + +export default function FloatingChatButton({ onClick }: FloatingChatButtonProps) { + const [visible, setVisible] = useState(true); + + const handleMouseEnter = useCallback(() => setVisible(true), []); + + const handleMouseLeave = useCallback(() => setVisible(false), []); + + useEffect(() => { + let tooltipTimer: ReturnType; + tooltipTimer = setTimeout(() => setVisible(false), 5000); + return () => clearTimeout(tooltipTimer); + }, []); + + return ( +
+ {visible && ( +
+ AI-Powered Search Beta + +
+ )} + +
+ ); +} diff --git a/apps/base-docs/src/components/DocChat/ResponseFeedback.tsx b/apps/base-docs/src/components/DocChat/ResponseFeedback.tsx new file mode 100644 index 0000000000..f954165000 --- /dev/null +++ b/apps/base-docs/src/components/DocChat/ResponseFeedback.tsx @@ -0,0 +1,109 @@ +import React, { useCallback } from 'react'; +import { ConversationMessage, logGptEvent, setSessionConversation } from './docChat'; + +import Icon from '../Icon'; + +import styles from './styles.module.css'; + +const logResponseFeedback = ( + conversationId: number, + messageId: number | undefined, + isHelpful: boolean, +) => { + const data = { + message_id: messageId, + rating_value: isHelpful ? 1 : -1, + }; + + fetch('/api/rateMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }).catch((error) => console.error(error)); + + logGptEvent('gpt_feedback', { + conversation_id: conversationId, + message_id: messageId, + response_helpful: isHelpful, + }); +}; + +type ResponseFeedbackProps = { + responseIndex: number; + messageId?: number; + conversationId: number; + conversation: ConversationMessage[]; + setConversation: ( + conversation: (prevState: ConversationMessage[]) => ConversationMessage[], + ) => void; +}; + +export default function ResponseFeedback({ + responseIndex, + messageId, + conversationId, + conversation, + setConversation, +}: ResponseFeedbackProps) { + const helpful = conversation[responseIndex].helpful; + const feedbackSubmitted = conversation[responseIndex].helpful !== null; + + const handleClick = useCallback( + (isHelpful: boolean) => { + setConversation((prevState: ConversationMessage[]) => { + const newState = prevState.map((message, i) => { + if (i === responseIndex) { + return { + ...message, + helpful: isHelpful, + }; + } + return message; + }); + + setSessionConversation(newState); + + return newState; + }); + + logResponseFeedback(conversationId, messageId, isHelpful); + }, + [conversation], + ); + + const handleHelpfulClick = useCallback(() => handleClick(true), []); + + const handleNotHelpfulClick = useCallback(() => handleClick(false), []); + + return ( + <> +
+ {feedbackSubmitted ? 'Thank you for your feedback!' : 'Was this response helpful?'} +
+ +
+ + + +
+ + ); +} diff --git a/apps/base-docs/src/components/DocChat/ResponseSource.tsx b/apps/base-docs/src/components/DocChat/ResponseSource.tsx new file mode 100644 index 0000000000..71397985ef --- /dev/null +++ b/apps/base-docs/src/components/DocChat/ResponseSource.tsx @@ -0,0 +1,38 @@ +import React, { useCallback } from 'react'; +import { logGptEvent } from './docChat'; + +import styles from './styles.module.css'; + +type ResponseSourceProps = { + conversationId: number; + messageId?: number; + source: string; + index: number; +}; + +export default function ResponseSource({ + conversationId, + messageId, + source, + index, +}: ResponseSourceProps) { + const handleSourceClick = useCallback(() => { + logGptEvent('gpt_source_clicked', { + conversation_id: conversationId, + message_id: messageId, + source_url: source, + }); + }, []); + + return ( + + {`${index + 1}. ${source}`} + + ); +} diff --git a/apps/base-docs/src/components/DocChat/docChat.ts b/apps/base-docs/src/components/DocChat/docChat.ts new file mode 100644 index 0000000000..307a3888c4 --- /dev/null +++ b/apps/base-docs/src/components/DocChat/docChat.ts @@ -0,0 +1,255 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source'; + +// Log Base GPT CCA event +type GptEvent = + | 'gpt_conversation_created' + | 'gpt_prompt_submitted' + | 'gpt_source_clicked' + | 'gpt_feedback'; + +type GptConversationCreatedAttributes = { + conversation_id: number; +}; + +type GptPromptSubmittedAttributes = { + conversation_id: number; + prompt: string; +}; + +type GptSourceClickedAttributes = { + conversation_id: number; + message_id: number; + source_url: string; +}; + +type GptFeedbackAttributes = { + conversation_id: number; + message_id: number; + response_helpful: boolean; +}; + +type GptEventAttributes = + | GptConversationCreatedAttributes + | GptPromptSubmittedAttributes + | GptSourceClickedAttributes + | GptFeedbackAttributes; + +export const logGptEvent = (type: GptEvent, attributes: GptEventAttributes) => { + if (window.ClientAnalytics) { + const { logEvent, ActionType, ComponentType } = window.ClientAnalytics; + + let path: string = window.location.pathname; + + // Remove trailing slash + if (path !== '/' && path.endsWith('/')) { + path = path.slice(0, -1); + } + + const updatedAttributes = { + ...attributes, + page_path: path, + action: ActionType.click, + component_type: ComponentType.button, + }; + + logEvent(type, updatedAttributes); + } +}; + +// Get Conversation ID +export async function getConversationId(): Promise { + let id: string = sessionStorage.getItem('BASE_AI_CONVERSATION_ID') ?? ''; + + if (!id) { + const response = await fetch('https://api.mendable.ai/v1/newConversation', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: '0ab8984e-327c-4a8b-bea3-769ca01fac35', + }), + }); + + const data: { conversation_id: string } = (await response.json()) as { + conversation_id: string; + }; + + if (data.conversation_id) { + id = data.conversation_id; + sessionStorage.setItem('BASE_AI_CONVERSATION_ID', id); + + logGptEvent('gpt_conversation_created', { + conversation_id: parseInt(id), + }); + } + } + + return parseInt(id); +} + +// Set and Get Session Storage Conversation +export function setSessionConversation(conversation: ConversationMessage[]) { + const conversationString = JSON.stringify(conversation); + sessionStorage.setItem('BASE_AI_CONVERSATION', conversationString); +} + +export function getSessionConversation(): ConversationMessage[] { + const conversationString: string = sessionStorage.getItem('BASE_AI_CONVERSATION') ?? '[]'; + + const conversation: ConversationMessage[] = JSON.parse( + conversationString, + ) as ConversationMessage[]; + + return conversation; +} + +// POST Prompt and Stream Response +export type ChatHistoryMessage = { + prompt: string; + response: string; +}; + +export type ConversationMessage = { + type: 'prompt' | 'response'; + content: string; + sources?: string[]; + helpful?: boolean | null; + messageId?: number; +}; + +type ResponseSource = { + content: string; + data_id: string; + date_added: string; + id: number; + link: string; + manual_add: boolean; + relevance_score: number; + text: number; +}; + +export let controller: AbortController; + +export async function streamPromptResponse( + conversationId: number, + prompt: string, + setIsLoading: (isLoading: boolean) => void, + isGenerating: boolean, + setIsGenerating: (isGenerating: boolean) => void, + chatHistory: ChatHistoryMessage[], + setChatHistory: (chatHistory: (prevState: ChatHistoryMessage[]) => ChatHistoryMessage[]) => void, + setConversation: ( + conversation: (prevState: ConversationMessage[]) => ConversationMessage[], + ) => void, +) { + try { + let fullResponse = ''; + let responseSources: ResponseSource[]; + let messageId: number; + + controller = new AbortController(); + + logGptEvent('gpt_prompt_submitted', { + conversation_id: conversationId, + prompt, + }); + + const url = 'https://api.mendable.ai/v1/mendableChat'; + + const data = { + api_key: '0ab8984e-327c-4a8b-bea3-769ca01fac35', + question: prompt, + history: chatHistory, + conversation_id: conversationId, + retriever_options: { + num_chunks: 4, + }, + }; + + await fetchEventSource(url, { + method: 'POST', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + }, + openWhenHidden: true, + body: JSON.stringify(data), + signal: controller.signal, + onmessage(event: unknown) { + const parsedData = JSON.parse(event.data); + const chunk: string = parsedData.chunk; + + if (chunk === '<|source|>') { + responseSources = parsedData.metadata as ResponseSource[]; + return; + } else if (chunk === '<|message_id|>') { + messageId = parsedData.metadata as number; + return; + } + + // End loading spinner and show Stop Generating button + if (!isGenerating) { + setIsLoading(false); + setIsGenerating(true); + } + + // Update full response string + fullResponse = fullResponse.concat(chunk); + + // Update rendered conversation data for current response + setConversation((prevState: ConversationMessage[]) => { + const currentResponse = prevState.slice(-1)[0]; + const updatedResponse = { + ...currentResponse, + content: currentResponse.content.concat(chunk), + }; + + return [...prevState.slice(0, -1), updatedResponse]; + }); + + return; + }, + onclose() { + // Add Mendable message ID and sources to current response data + const sourceURLs: string[] = responseSources.map((source) => source.link); + + setConversation((prevState: ConversationMessage[]) => { + const currentResponse = prevState.slice(-1)[0]; + const updatedResponse = { + ...currentResponse, + sources: sourceURLs, + helpful: null, + messageId, + }; + + const newState = [...prevState.slice(0, -1), updatedResponse]; + setSessionConversation(newState); + + return newState; + }); + + // Update chat history for Mendable API requests + setChatHistory((prevState: ChatHistoryMessage[]) => { + const currentResponse = prevState.slice(-1)[0]; + const updatedResponse = { + ...currentResponse, + response: fullResponse, + }; + + return [...prevState.slice(0, -1), updatedResponse]; + }); + + // Hide Stop Generating button + setIsGenerating(false); + return; + }, + onerror(err: unknown) { + console.error(err); + return; + }, + }); + } catch (err) { + console.error(err); + } +} diff --git a/apps/base-docs/src/components/DocChat/index.tsx b/apps/base-docs/src/components/DocChat/index.tsx new file mode 100644 index 0000000000..5683d9ebcd --- /dev/null +++ b/apps/base-docs/src/components/DocChat/index.tsx @@ -0,0 +1,18 @@ +import React, { useState, useCallback } from 'react'; +import FloatingChatButton from './FloatingChatButton'; +import ChatModal from './ChatModal'; + +export default function DocFeedback() { + const [visible, setVisible] = useState(false); + + const handleModalOpen = useCallback(() => { + setVisible(true); + }, []); + + return ( + <> + + + + ); +} diff --git a/apps/base-docs/src/components/DocChat/styles.module.css b/apps/base-docs/src/components/DocChat/styles.module.css new file mode 100644 index 0000000000..cfba130cfe --- /dev/null +++ b/apps/base-docs/src/components/DocChat/styles.module.css @@ -0,0 +1,404 @@ +/* Floating Chat Button Styles */ +.floatingChatButtonContainer { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 100; +} + +.floatingChatButton { + background-color: transparent; + border-radius: 50%; + cursor: pointer; +} +[data-theme='light'] .floatingChatButton { + fill: black; +} +[data-theme='dark'] .floatingChatButton { + fill: white; +} + +.floatingChatButtonTooltip { + position: absolute; + top: -2.75rem; + right: 0; + border-radius: 0.25rem; + font-size: 0.75rem; + padding: 3px 10px; + white-space: nowrap; + animation: tooltip-fade-in 0.2s ease-in-out; +} +[data-theme='light'] .floatingChatButtonTooltip { + color: white; + background: black; +} +[data-theme='dark'] .floatingChatButtonTooltip { + color: black; + background: white; +} + +.tooltipPoint { + height: 0.6rem; + width: 0.6rem; + rotate: 45deg; + position: absolute; + bottom: -0.25rem; + right: 2rem; + border-radius: 1px; +} +[data-theme='light'] .tooltipPoint { + background-color: black; +} +[data-theme='dark'] .tooltipPoint { + background-color: white; +} + +@keyframes tooltip-fade-in { + from { + opacity: 0%; + transform: translateY(0.5rem); + } + + to { + opacity: 100%; + transform: translateY(0); + } +} + +/* Chat Modal Styles */ +.chatModalBody { + padding: 1.5rem; + flex-grow: 1; +} + +.chatModalFooter { + padding: 1.5rem; + flex-grow: 0; +} +[data-theme='light'] .chatModalFooter { + border-top: 1px solid rgba(91, 97, 110, 0.2); +} +[data-theme='dark'] .chatModalFooter { + border-top: 1px solid rgba(138, 145, 158, 0.2); +} + +.conversationContainer { + width: 100%; + max-height: 500px; + max-width: 750px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + position: relative; + overflow-y: scroll; +} +[data-theme='light'] .conversationContainer * { + fill: black; +} +[data-theme='dark'] .conversationContainer * { + fill: white; +} + +.chatMessageContainer { + width: 100%; +} + +.chatMessage { + width: 100%; + display: flex; + gap: 0.75rem; + padding-right: 2.25rem; +} + +.chatMessageIcon { + display: flex; + flex-shrink: 0; +} + +.chatMessageContent { + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow-x: auto; +} + +.chatMessageContent pre { + overflow-x: scroll; +} + +.chatMessageContent code { + font-size: 0.9rem; +} + +/* Remove margin from lists, first list items, and code blocks in parsed markdown */ +.chatMessageContent ol, +.chatMessageContent ul, +.chatMessageContent ol li:first-of-type p, +.chatMessageContent ul li:first-of-type p, +.chatMessageContent pre { + margin: 0 !important; +} + +.responseRatingPrompt { + padding: 1.5rem 0 0 2.25rem; +} + +.responseRatingButtonContainer { + display: flex; + padding: 0.25rem 0 0 2.25rem; +} + +.helpfulButton, +.notHelpfulButton { + appearance: none; + background-color: transparent; + border-radius: 50%; + padding: 0.5rem 0.6rem; +} + +.helpfulButton:hover, +.notHelpfulButton:hover { + cursor: pointer; + transform: rotate(-8deg); + transition-property: all; + transition-duration: 200ms; +} + +[data-theme='light'] .helpfulButton svg, +[data-theme='light'] .notHelpfulButton svg { + fill: black; +} + +[data-theme='dark'] .helpfulButton svg, +[data-theme='dark'] .notHelpfulButton svg { + fill: white; +} + +[data-theme='light'] .helpfulButton:hover, +[data-theme='light'] .notHelpfulButton:hover { + background-color: rgb(238, 240, 243); +} + +[data-theme='dark'] .helpfulButton:hover, +[data-theme='dark'] .notHelpfulButton:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.notHelpfulButton > svg { + margin-bottom: -5px; + transform: scale(-1, 1); +} + +/* Override styles for disabled buttons */ +.disabledButton { + opacity: 0.5 !important; + cursor: default !important; +} +.disabledButton:hover { + transform: none !important; + background-color: transparent !important; +} + +.chatMessageSourcesLabel { + width: 100%; + font-weight: 500; + padding: 1rem 0 0 2.25rem; +} + +.chatMessageSourcesContainer { + width: 100%; + display: flex; + flex-wrap: wrap; + padding: 0 2.25rem; +} + +.chatMessageSource { + max-width: 215px; + padding: 0.25rem 0.5rem; + margin: 0.5rem 0.5rem 0 0; + font-size: 0.75rem; + border-radius: 0.33rem; + overflow-x: hidden; + white-space: nowrap; +} +[data-theme='light'] .chatMessageSource { + color: black; + border: 1px solid rgba(91, 97, 110, 0.5); +} +[data-theme='dark'] .chatMessageSource { + color: white; + border: 1px solid rgba(138, 145, 158, 0.67); +} + +.chatMessageDivider { + width: 100%; +} +[data-theme='light'] .chatMessageDivider { + border-top: 1px solid rgba(91, 97, 110, 0.2); +} +[data-theme='dark'] .chatMessageDivider { + border-top: 1px solid rgba(138, 145, 158, 0.2); +} + +.resetButton, +.stopGeneratingButton { + font-family: CoinbaseSans; + padding: 0.25rem 0.5rem; + border-radius: 0.33rem; + display: inline-flex; + gap: 0.5rem; + align-items: center; + appearance: none; + transition: all 0.1s ease-in-out; + cursor: pointer; +} +[data-theme='dark'] .resetButton, +[data-theme='dark'] .stopGeneratingButton { + fill: white; + background: rgba(58, 61, 69, 0.9); +} +[data-theme='dark'] .resetButton:hover, +[data-theme='dark'] .stopGeneratingButton:hover { + background: rgb(58, 61, 69); +} +[data-theme='dark'] .resetButton:active, +[data-theme='dark'] .stopGeneratingButton:active { + background: rgb(50, 52, 59); +} + +.resetButton { + position: absolute; + top: 1.4rem; + right: 1.5rem; + z-index: 250; +} + +.stopGeneratingButton { + align-self: center; +} + +.searchingDocsMessage { + margin-top: -1.5rem; + width: 100%; + font-size: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.promptForm { + width: 100%; + border-radius: 0.5rem; + display: flex; +} +[data-theme='light'] .promptForm { + border: 1px solid rgba(91, 97, 110, 0.5); +} +[data-theme='dark'] .promptForm { + border: 1px solid rgba(138, 145, 158, 0.67); +} + +.promptInput { + width: 100%; + padding: 1rem; + font-size: 1rem; + outline: none; + appearance: none; + background-color: transparent; + border-radius: 0.5rem; +} + +[data-theme='light'] .promptInput::placeholder { + color: rgba(91, 97, 110, 0.5); +} +[data-theme='dark'] .promptInput::placeholder { + color: rgba(138, 145, 158, 0.67); +} + +.promptInputIcon { + padding: 1rem; + display: flex; +} +[data-theme='light'] .promptInputIcon { + fill: rgba(91, 97, 110, 0.5); +} +[data-theme='dark'] .promptInputIcon { + fill: rgba(191, 196, 202, 0.67); +} + +.submitPromptButton { + cursor: pointer; + transition: all 0.15s ease-in-out; + appearance: none; + background: transparent; + display: flex; + align-items: center; +} +[data-theme='light'] .submitPromptButton:hover { + fill: rgba(91, 97, 110, 0.67); +} +[data-theme='light'] .submitPromptButton:active { + fill: rgba(75, 79, 90, 0.67); +} +[data-theme='dark'] .submitPromptButton:hover { + fill: rgba(191, 196, 202, 0.8); +} +[data-theme='dark'] .submitPromptButton:active { + fill: rgba(164, 168, 172, 0.8); +} + +.disclaimerText { + font-size: 0.75rem; + text-align: center; + line-height: 1rem; + margin-top: 0.5rem; +} + +.loadingSpinner { + display: flex; + justify-content: center; + align-items: center; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (max-width: 832px) { + .floatingChatButtonContainer { + bottom: 1rem; + right: 1rem; + } + + .conversationContainer { + max-width: 100%; + } +} + +@media (max-width: 500px) { + .chatMessage { + padding-right: 0; + } + + .chatMessage pre { + padding: 1rem; + } + + .chatMessageSource { + max-width: 200px; + } +} + +@media (max-height: 700px) { + .conversationContainer { + max-height: 350px; + } +} diff --git a/apps/base-docs/src/components/DocFeedback/index.tsx b/apps/base-docs/src/components/DocFeedback/index.tsx index 07649f1b86..c998d48fec 100644 --- a/apps/base-docs/src/components/DocFeedback/index.tsx +++ b/apps/base-docs/src/components/DocFeedback/index.tsx @@ -18,7 +18,7 @@ const logDocFeedback = (isHelpful: boolean, reason?: string) => { logEvent('doc_feedback', { action: ActionType.click, - componentType: ComponentType.button, + component_type: ComponentType.button, doc_helpful: isHelpful, doc_feedback_reason: reason ?? null, page_path: path, @@ -99,6 +99,7 @@ export default function DocFeedback() { href={`https://github.com/base-org/web/blob/master/apps/base-docs/${docFilePath}?plain=1`} target="_blank" className={styles.editDocLink} + rel="noreferrer" > Edit this page on GitHub diff --git a/apps/base-docs/src/components/Icon/index.tsx b/apps/base-docs/src/components/Icon/index.tsx index 03586e294f..21a56220dc 100644 --- a/apps/base-docs/src/components/Icon/index.tsx +++ b/apps/base-docs/src/components/Icon/index.tsx @@ -1,5 +1,17 @@ type IconProps = { - name: 'thumbs-up' | 'thumbs-up-filled' | 'thumbs-down' | 'thumbs-down-filled' | 'caret-down'; + name: + | 'thumbs-up' + | 'thumbs-up-filled' + | 'thumbs-down' + | 'thumbs-down-filled' + | 'caret-down' + | 'external-link' + | 'paper-airplane' + | 'base-logo' + | 'avatar' + | 'loading-spinner' + | 'undo' + | 'stop'; width?: string; height?: string; }; @@ -109,5 +121,99 @@ export default function Icon({ name, width = '24', height = '24' }: IconProps) { ); } + if (name === 'paper-airplane') { + return ( + + + + ); + } + if (name === 'base-logo') { + return ( + + + + ); + } + if (name === 'avatar') { + return ( + + + + ); + } + if (name === 'loading-spinner') { + return ( + + + + + ); + } + if (name === 'undo') { + return ( + + + + ); + } + if (name === 'stop') { + return ( + + + + ); + } return null; } diff --git a/apps/base-docs/src/components/Modal/index.tsx b/apps/base-docs/src/components/Modal/index.tsx index ccfce2ce09..ccf56e9836 100644 --- a/apps/base-docs/src/components/Modal/index.tsx +++ b/apps/base-docs/src/components/Modal/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import styles from './styles.module.css'; @@ -14,6 +14,19 @@ export default function Modal({ children, onRequestClose, visible }: ModalProps) [], ); + // Prevent background scrolling when modal is open + useEffect(() => { + if (visible) { + const el = document.getElementById('__docusaurus'); + el?.classList.add('no-scroll'); + } + + return () => { + const el = document.getElementById('__docusaurus'); + el?.classList.remove('no-scroll'); + }; + }, [visible]); + return (
)} {children} - {() => } + + {() => ( + <> + + + + )} +
); } diff --git a/apps/base-docs/src/utils/docusaurusCustomFields.ts b/apps/base-docs/src/utils/docusaurusCustomFields.ts new file mode 100644 index 0000000000..86c8b54d3c --- /dev/null +++ b/apps/base-docs/src/utils/docusaurusCustomFields.ts @@ -0,0 +1,11 @@ +type DocusaurusConfig = { + default: { + customFields: { + nodeEnv: string; + }; + }; +}; + +const docusaurusConfig = require('@generated/docusaurus.config') as DocusaurusConfig; + +export const { customFields } = docusaurusConfig.default; diff --git a/apps/base-docs/src/utils/initCCA.ts b/apps/base-docs/src/utils/initCCA.ts index b6e94dec46..e597fb847a 100644 --- a/apps/base-docs/src/utils/initCCA.ts +++ b/apps/base-docs/src/utils/initCCA.ts @@ -2,12 +2,11 @@ // They recommended disabling linting and type-checking for now, since this version is not typed. /* eslint-disable */ // @ts-nocheck -const docusaurusConfig = require('@generated/docusaurus.config'); import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import { customFields } from './docusaurusCustomFields'; import { setCookie, getCookie, deserializeCookie } from './cookieManagement'; import { TrackingPreference } from '@coinbase/cookie-manager'; -const { customFields } = docusaurusConfig.default; const isDevelopment = customFields.nodeEnv === 'development'; // Initialize Client Analytics diff --git a/apps/base-docs/src/utils/marked.ts b/apps/base-docs/src/utils/marked.ts new file mode 100644 index 0000000000..4bdfd323bd --- /dev/null +++ b/apps/base-docs/src/utils/marked.ts @@ -0,0 +1,15 @@ +import { marked } from 'marked'; +import * as DOMPurify from 'dompurify'; + +// Add target="_blank" to anchor tags +const renderer = new marked.Renderer(); +renderer.link = (href, title, text) => + `${text}`; + +marked.use({ renderer }); + +export default marked; + +export function parseMarkdown(markdown: string) { + return DOMPurify.sanitize(marked.parse(markdown) as string); +} diff --git a/yarn.lock b/yarn.lock index ddfd934f1f..e1ccff6910 100644 --- a/yarn.lock +++ b/yarn.lock @@ -231,13 +231,19 @@ __metadata: "@docusaurus/preset-classic": 2.3.1 "@docusaurus/theme-common": 2.3.1 "@mdx-js/react": ^1.6.22 + "@microsoft/fetch-event-source": ^2.0.1 "@rainbow-me/rainbowkit": ^1.0.4 "@tsconfig/docusaurus": ^1.0.5 + "@types/dompurify": ^3.0.5 + body-parser: ^1.20.2 docusaurus-node-polyfills: ^1.0.0 + dompurify: ^3.0.8 dotenv: ^16.3.1 express: ^4.18.2 express-basic-auth: ^1.2.1 lodash: ^4.17.21 + marked: ^11.1.1 + node-fetch: 2 react: ^18.2.0 react-dom: ^18.2.0 typescript: ^5.1.3 @@ -4374,6 +4380,13 @@ __metadata: languageName: node linkType: hard +"@microsoft/fetch-event-source@npm:^2.0.1": + version: 2.0.1 + resolution: "@microsoft/fetch-event-source@npm:2.0.1" + checksum: a50e1c0f33220206967266d0a4bbba0703e2793b079d9f6e6bfd48f71b2115964a803e14cf6e902c6fab321edc084f26022334f5eaacc2cec87f174715d41852 + languageName: node + linkType: hard + "@motionone/animation@npm:^10.15.1, @motionone/animation@npm:^10.16.3": version: 10.16.3 resolution: "@motionone/animation@npm:10.16.3" @@ -6281,6 +6294,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.5": + version: 3.0.5 + resolution: "@types/dompurify@npm:3.0.5" + dependencies: + "@types/trusted-types": "*" + checksum: ffc34eca6a4536e1c8c16a47cce2623c5a118a9785492e71230052d92933ff096d14326ff449031e8dfaac509413222372d8f2b28786a13159de6241df716185 + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.3": version: 3.7.6 resolution: "@types/eslint-scope@npm:3.7.6" @@ -6748,6 +6770,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:*": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3 + languageName: node + linkType: hard + "@types/trusted-types@npm:^2.0.2": version: 2.0.5 resolution: "@types/trusted-types@npm:2.0.5" @@ -9025,6 +9054,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^1.20.2": + version: 1.20.2 + resolution: "body-parser@npm:1.20.2" + dependencies: + bytes: 3.1.2 + content-type: ~1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 2.5.2 + type-is: ~1.6.18 + unpipe: 1.0.0 + checksum: 14d37ec638ab5c93f6099ecaed7f28f890d222c650c69306872e00b9efa081ff6c596cd9afb9930656aae4d6c4e1c17537bea12bb73c87a217cb3cfea8896737 + languageName: node + linkType: hard + "bonjour-service@npm:^1.0.11": version: 1.1.1 resolution: "bonjour-service@npm:1.1.1" @@ -10073,7 +10122,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:~1.0.4": +"content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 @@ -11180,6 +11229,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.0.8": + version: 3.0.8 + resolution: "dompurify@npm:3.0.8" + checksum: cac660ccae15a9603f06a85344da868a4c3732d8b57f7998de0f421eb4b9e67d916be52e9bb2a57b2f95b49e994cc50bcd06bb87f2cb2849cf058bdf15266237 + languageName: node + linkType: hard + "domutils@npm:^2.5.2, domutils@npm:^2.8.0": version: 2.8.0 resolution: "domutils@npm:2.8.0" @@ -16092,6 +16148,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^11.1.1": + version: 11.1.1 + resolution: "marked@npm:11.1.1" + bin: + marked: bin/marked.js + checksum: e30e16bf1d2c6627fff4369ffef73a1fbec629c5d18be76fc1f9c36f3df96499845bb7785f73313d06082b4562307e4f314f35eaa24ac737c176234b4bf24982 + languageName: node + linkType: hard + "match-sorter@npm:^6.0.2": version: 6.3.1 resolution: "match-sorter@npm:6.3.1" @@ -16841,7 +16906,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12": +"node-fetch@npm:2, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -18742,6 +18807,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:2.5.2": + version: 2.5.2 + resolution: "raw-body@npm:2.5.2" + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + checksum: ba1583c8d8a48e8fbb7a873fdbb2df66ea4ff83775421bfe21ee120140949ab048200668c47d9ae3880012f6e217052690628cf679ddfbd82c9fc9358d574676 + languageName: node + linkType: hard + "rc@npm:1.2.8, rc@npm:^1.2.8": version: 1.2.8 resolution: "rc@npm:1.2.8"