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;
}
}
`}
`;