diff --git a/apps/base-docs/.env.example b/apps/base-docs/.env.example new file mode 100644 index 0000000..7043bf7 --- /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 0000000..4c49bd7 --- /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 0d24963..5ef6f55 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 5435433..44742dd 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 554bdf2..85a69ae 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 b157c8b..46b5013 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 0000000..e18199b --- /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 0000000..e1baefc --- /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 0000000..8e65c6f --- /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 0000000..f954165 --- /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 0000000..7139798 --- /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 0000000..307a388 --- /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 0000000..5683d9e --- /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 0000000..cfba130 --- /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 07649f1..c998d48 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 03586e2..21a5622 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 ccfce2c..ccf56e9 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 0000000..86c8b54 --- /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 b6e94de..e597fb8 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 0000000..4bdfd32 --- /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 ddfd934..e1ccff6 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"