import React, { memo, useState } from 'react'; import styled from 'styled-components'; import type { JSX } from 'react'; import { FeedbackType, type SearchAiMessageResource, type ToolCall, type ContentSegment, } from '@redocly/theme/core/types'; import { TOOL_CALL_DISPLAY_TEXT } from '@redocly/theme/core/constants'; import { Link } from '@redocly/theme/components/Link/Link'; import { Tag } from '@redocly/theme/components/Tag/Tag'; import { AiSearchConversationRole } from '@redocly/theme/core/constants'; import { useThemeHooks } from '@redocly/theme/core/hooks'; import { Markdown } from '@redocly/theme/components/Markdown/Markdown'; import { DocumentIcon } from '@redocly/theme/icons/DocumentIcon/DocumentIcon'; import { AiStarsIcon } from '@redocly/theme/icons/AiStarsIcon/AiStarsIcon'; import { CheckmarkOutlineIcon } from '@redocly/theme/icons/CheckmarkOutlineIcon/CheckmarkOutlineIcon'; import { SearchAiActionButtons } from '@redocly/theme/components/Search/SearchAiActionButtons'; import { SearchAiNegativeFeedbackForm } from '@redocly/theme/components/Search/SearchAiNegativeFeedbackForm'; function MarkdownSegment({ text }: { text: string }): JSX.Element { const { useMarkdownText } = useThemeHooks(); const markdown = useMarkdownText(text); return ; } function getToolCallDisplayText(toolName: string): { inProgressText: string; completedText: string; } { return ( TOOL_CALL_DISPLAY_TEXT[toolName] ?? { inProgressText: `Executing ${toolName}...`, completedText: `${toolName} executed`, } ); } export type SearchAiMessageProps = { role: AiSearchConversationRole; content: string; isThinking?: boolean; resources?: SearchAiMessageResource[]; className?: string; messageId?: string; feedback?: FeedbackType; onFeedbackChange: (messageId: string, feedback: FeedbackType | undefined) => void; toolCalls?: ToolCall[]; contentSegments?: ContentSegment[]; }; function SearchAiMessageComponent({ role, content, isThinking, resources, className, messageId, feedback, onFeedbackChange, toolCalls = [], contentSegments = [{ type: 'text' as const, text: content }], }: SearchAiMessageProps): JSX.Element { const { useTranslate, useTelemetry } = useThemeHooks(); const { translate } = useTranslate(); const telemetry = useTelemetry(); const [feedbackSent, setFeedbackSent] = useState(false); const hasResources = !isThinking && resources && resources.length > 0; const resourcesCount = resources?.length ?? 0; const showSuccessMessage = feedbackSent && feedback; const isLoading = isThinking && content.length === 0 && toolCalls.length === 0; const sendFeedbackTelemetry = (feedbackValue: FeedbackType, dislikeReason?: string) => { if (!messageId) return; try { telemetry.sendSearchAIFeedbackMessage([ { object: 'feedback', feedback: feedbackValue, messageId, reason: dislikeReason, }, ]); } catch (error) { console.error('Error sending feedback', error); } }; const handleFeedbackClick = (feedbackValue: FeedbackType, reason?: string) => { if (!messageId) { return; } if (!reason) { onFeedbackChange(messageId, feedbackValue); } sendFeedbackTelemetry(feedbackValue, reason); if (feedbackValue === FeedbackType.Like || reason) { setFeedbackSent(true); } }; return ( {role === AiSearchConversationRole.ASSISTANT && ( )} {role === AiSearchConversationRole.ASSISTANT ? ( <> {contentSegments.map((segment, index) => { if (segment.type === 'tool') { const toolCallCompleted = Boolean(segment.toolCall.result); const { inProgressText, completedText } = getToolCallDisplayText( segment.toolCall.name, ); const toolCallDisplayText = toolCallCompleted ? completedText : inProgressText; return ( {toolCallDisplayText} ); } return ; })} {hasResources && ( <> {translate('search.ai.resourcesFound.basedOn', 'Based on')} {resourcesCount}{' '} {translate('search.ai.resourcesFound.resources', 'resources')} {resources?.map((resource, index) => ( } > {resource.title} ))} )} {isLoading && ( )} {content.length > 0 && ( )} {messageId && feedback === FeedbackType.Dislike && !showSuccessMessage && ( { onFeedbackChange(messageId, feedback); setFeedbackSent(true); }} onSubmit={(reason) => handleFeedbackClick(FeedbackType.Dislike, reason)} /> )} {showSuccessMessage && ( {translate('search.ai.feedback.thanks', 'Thank you for your feedback!')} )} ) : ( {content} )} ); } function areResourcesEqual( prev?: SearchAiMessageResource[], next?: SearchAiMessageResource[], ): boolean { if (prev === next) return true; if (!prev || !next || prev.length !== next.length) return false; return prev.every((resource, index) => { const nextResource = next[index]; return resource.url === nextResource.url && resource.title === nextResource.title; }); } export const SearchAiMessage = memo(SearchAiMessageComponent, (prevProps, nextProps) => { return ( prevProps.role === nextProps.role && prevProps.content === nextProps.content && prevProps.isThinking === nextProps.isThinking && prevProps.messageId === nextProps.messageId && prevProps.feedback === nextProps.feedback && prevProps.onFeedbackChange === nextProps.onFeedbackChange && areResourcesEqual(prevProps.resources, nextProps.resources) && prevProps.toolCalls?.length === nextProps.toolCalls?.length && prevProps.contentSegments === nextProps.contentSegments ); }); const SearchAiMessageWrapper = styled.div<{ role: string }>` display: flex; flex-direction: row; align-items: flex-start; width: 100%; justify-content: ${({ role }) => role === AiSearchConversationRole.USER ? 'flex-end' : 'flex-start'}; `; const MessageContentWrapper = styled.div` display: flex; flex-direction: column; gap: var(--spacing-sm); max-width: 80%; min-width: 0; `; const ResponseText = styled(Markdown)` color: var(--search-ai-text-color); font-size: var(--search-ai-text-font-size); line-height: var(--search-ai-text-line-height); font-family: inherit; white-space: break-spaces; article { > *:first-child { margin-top: 0; } > *:last-child { margin-bottom: 0; } } `; const MessageWrapper = styled.div<{ role: string }>` padding: ${({ role }) => role === AiSearchConversationRole.USER ? 'var(--spacing-sm)' : 'var(--spacing-sm) var(--spacing-sm) var(--spacing-xs) var(--spacing-sm)'}; border-radius: var(--border-radius-lg); width: fit-content; max-width: 100%; word-wrap: break-word; white-space: pre-wrap; background-color: ${({ role }) => role === AiSearchConversationRole.USER ? 'var(--search-ai-user-bg-color)' : 'var(--search-ai-assistant-bg-color)'}; border: ${({ role }) => role === AiSearchConversationRole.USER ? 'none' : 'var(--search-ai-assistant-border)'}; color: ${({ role }) => role === AiSearchConversationRole.USER ? 'var(--search-ai-user-text-color)' : 'var(--search-ai-assistant-text-color)'}; `; const ResourcesWrapper = styled.div` gap: var(--search-ai-resources-gap); display: flex; flex-direction: column; margin: 0; `; const FeedbackWrapper = styled.div` display: flex; flex-direction: row; gap: var(--search-ai-feedback-gap); margin-top: var(--spacing-sm); `; const ResourcesTitle = styled.div` font-weight: var(--search-ai-resources-title-font-weight); font-size: var(--search-ai-resources-title-font-size); line-height: var(--search-ai-resources-title-line-height); `; const ResourceTagsWrapper = styled.div` display: flex; flex-wrap: wrap; gap: var(--search-ai-resource-tags-gap); `; const ResourceTag = styled(Tag)` .tag-default { --tag-color: var(--search-ai-resource-tag-text-color); max-width: 100%; overflow: hidden; display: inline-block; } svg { min-width: var(--search-ai-resource-tag-icon-size); min-height: var(--search-ai-resource-tag-icon-size); flex-shrink: 0; } > div { overflow: hidden; word-break: break-word; white-space: normal; max-width: 100%; } `; const ThinkingDotsWrapper = styled.div` display: flex; gap: var(--search-ai-thinking-dots-gap); padding: var(--search-ai-thinking-dots-padding); `; const ThinkingDot = styled.div` width: var(--search-ai-thinking-dot-size); height: var(--search-ai-thinking-dot-size); border-radius: 50%; background: var(--search-ai-thinking-dot-color); animation: bounce 1.4s infinite ease-in-out; &:nth-child(1) { animation-delay: -0.32s; } &:nth-child(2) { animation-delay: -0.16s; } &:nth-child(3) { animation-delay: 0s; } @keyframes bounce { 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } } `; const SuccessMessageWrapper = styled.div` max-width: fit-content; display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); background: var(--color-success-bg); border: 1px solid var(--color-success-border); border-radius: var(--border-radius-lg); `; const SuccessMessageText = styled.div` font-size: var(--font-size-base); color: var(--color-success-darker); `; const ToolCallsInfoWrapper = styled.div` display: flex; flex-direction: column; gap: var(--spacing-xxs); margin: 0 0 var(--spacing-sm) 0; font-size: var(--font-size-xs); color: var(--search-ai-text-color); opacity: 0.6; `; const ToolCallInfoItem = styled.div` display: flex; align-items: center; gap: var(--spacing-xxs); `; const ToolCallText = styled.span<{ $isSearching?: boolean }>` font-weight: var(--font-weight-regular); ${({ $isSearching }) => $isSearching && ` animation: pulse 1.5s ease-in-out infinite; @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } `} `;