diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts b/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts index 13d5fe5e..fc5af826 100644 --- a/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts @@ -46,6 +46,17 @@ const translations: DocSearchTranslations & { likeButtonTitle: string; dislikeButtonTitle: string; thanksForFeedbackText: string; + feedbackPanelTitle: string; + feedbackDetailsPlaceholder: string; + feedbackDisclaimerText: string; + feedbackSubmitButtonText: string; + feedbackCloseButtonTitle: string; + feedbackTagIncorrect: string; + feedbackTagNotWhatIAsked: string; + feedbackTagSlowOrBuggy: string; + feedbackTagStyleOrTone: string; + feedbackTagSafetyOrLegal: string; + feedbackTagOther: string; preToolCallText: string; duringToolCallText: string; afterToolCallText: string; @@ -262,6 +273,61 @@ const translations: DocSearchTranslations & { message: 'Thanks for your feedback!', description: 'The text for thanks for feedback', }), + feedbackPanelTitle: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackPanelTitle', + message: 'What went wrong? (optional)', + description: 'The title of the negative feedback note panel', + }), + feedbackDetailsPlaceholder: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackDetailsPlaceholder', + message: 'Share some details...', + description: 'The placeholder for the negative feedback details textarea', + }), + feedbackDisclaimerText: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackDisclaimerText', + message: 'A copy of this chat will be included with your feedback.', + description: 'The disclaimer shown inside the negative feedback note panel', + }), + feedbackSubmitButtonText: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackSubmitButtonText', + message: 'Submit', + description: 'The submit button text for the negative feedback note panel', + }), + feedbackCloseButtonTitle: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackCloseButtonTitle', + message: 'Close', + description: 'The accessible title for the negative feedback note panel close button', + }), + feedbackTagIncorrect: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagIncorrect', + message: 'Incorrect or incomplete', + description: 'The "incorrect" reason chip for negative feedback', + }), + feedbackTagNotWhatIAsked: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagNotWhatIAsked', + message: 'Not what I asked for', + description: 'The "not what I asked for" reason chip for negative feedback', + }), + feedbackTagSlowOrBuggy: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagSlowOrBuggy', + message: 'Slow or buggy', + description: 'The "slow or buggy" reason chip for negative feedback', + }), + feedbackTagStyleOrTone: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagStyleOrTone', + message: 'Style or tone', + description: 'The "style or tone" reason chip for negative feedback', + }), + feedbackTagSafetyOrLegal: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagSafetyOrLegal', + message: 'Safety or legal concern', + description: 'The "safety or legal concern" reason chip for negative feedback', + }), + feedbackTagOther: translate({ + id: 'theme.SearchModal.askAiScreen.feedbackTagOther', + message: 'Other', + description: 'The "other" reason chip for negative feedback', + }), preToolCallText: translate({ id: 'theme.SearchModal.askAiScreen.preToolCallText', message: 'Searching...', diff --git a/bundlesize.config.json b/bundlesize.config.json index ef2862d8..eca76886 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "packages/docsearch-css/dist/style.css", - "maxSize": "6 kB" + "maxSize": "6.15 kB" }, { "path": "packages/docsearch-react/dist/umd/index.js", diff --git a/examples/demo-js/src/main.ts b/examples/demo-js/src/main.ts index f99e2a01..e655bde5 100644 --- a/examples/demo-js/src/main.ts +++ b/examples/demo-js/src/main.ts @@ -35,7 +35,7 @@ const sidepanelInstance = sidepanel({ indexName: 'docsearch', appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', - assistantId: 'askAIDemo', + assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', onReady: () => { // eslint-disable-next-line no-console console.log('[demo-js] sidepanel onReady()'); @@ -68,7 +68,6 @@ const docsearchInstance = docsearch({ indexName: 'docsearch', appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', - askAi: 'askAIDemo', interceptAskAiEvent: (initialMessage: InitialAskAiMessage) => { docsearchInstance.close(); sidepanelInstance.open(initialMessage); diff --git a/examples/demo-react/src/App.tsx b/examples/demo-react/src/App.tsx index 4462cb15..5586c4f3 100644 --- a/examples/demo-react/src/App.tsx +++ b/examples/demo-react/src/App.tsx @@ -101,7 +101,7 @@ function App(): JSX.Element { diff --git a/examples/demo-react/src/examples/dynamic-import-modal.tsx b/examples/demo-react/src/examples/dynamic-import-modal.tsx index c6356a1b..d63f247c 100644 --- a/examples/demo-react/src/examples/dynamic-import-modal.tsx +++ b/examples/demo-react/src/examples/dynamic-import-modal.tsx @@ -91,10 +91,7 @@ function DocSearch(): JSX.Element { appId="PMZUYBQDAK" apiKey="24b09689d5b4223813d9b8e48563c8f6" askAi={{ - assistantId: 'askAIDemo', - searchParameters: { - facetFilters: ['language:en'], - }, + assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', }} initialScrollY={window.scrollY} initialQuery={initialQuery} diff --git a/examples/demo-react/src/examples/w-hit-transformItems.tsx b/examples/demo-react/src/examples/w-hit-transformItems.tsx index 6ef2b3b2..42b8b8c2 100644 --- a/examples/demo-react/src/examples/w-hit-transformItems.tsx +++ b/examples/demo-react/src/examples/w-hit-transformItems.tsx @@ -26,7 +26,7 @@ export default function WTransformItems(): JSX.Element { appId="PMZUYBQDAK" apiKey="24b09689d5b4223813d9b8e48563c8f6" askAi={{ - assistantId: 'askAIDemo', + assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', }} insights={true} searchParameters={{ diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 8084e60c..4ae526f7 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -834,13 +834,194 @@ assistive tech users */ } .DocSearch-AskAiScreen-Actions { + display: flex; + flex-direction: column; + width: 100%; + gap: var(--docsearch-spacing); +} + +.DocSearch-AskAiScreen-Actions-Controls { display: flex; flex-direction: row; gap: 12px; align-items: center; - margin-inline-start: auto; + align-self: flex-end; } +/* Negative feedback note panel */ +.DocSearch-Feedback-Panel { + flex-basis: 100%; + width: 100%; + padding: var(--docsearch-spacing); + border-top: 1px solid var(--docsearch-subtle-color); + display: flex; + flex-direction: column; + gap: 12px; + text-align: start; + animation: fade-in 0.2s ease-in forwards; +} + +.DocSearch-Feedback-Panel-Header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.DocSearch-Feedback-Panel-Title { + margin: 0; + font-size: 1em; + font-weight: 600; + color: var(--docsearch-text-color); +} + +.DocSearch-Feedback-Panel-Close { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + cursor: pointer; + border-radius: var(--docsearch-border-radius); + color: var(--docsearch-icon-color); + transition: background-color 0.2s ease; +} + +.DocSearch-Feedback-Panel-Close:hover { + background: var(--docsearch-hit-highlight-color); +} + +.DocSearch-Feedback-Panel-Close svg { + width: 16px; + height: 16px; +} + +.DocSearch-Feedback-Panel-Reasons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.DocSearch-Feedback-Panel-Reason { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--docsearch-border-radius); + border: 1px solid var(--docsearch-subtle-color); + background: var(--docsearch-hit-background); + color: var(--docsearch-text-color); + font-family: inherit; + font-size: 1em; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease; +} + +.DocSearch-Feedback-Panel-Reason:hover { + border-color: var(--docsearch-highlight-color); + background: var(--docsearch-hit-highlight-color); +} + +.DocSearch-Feedback-Panel-Reason--selected { + border-color: var(--docsearch-highlight-color); + background: var(--docsearch-soft-primary-color); + color: var(--docsearch-highlight-color); + font-weight: 500; +} + +.DocSearch-Feedback-Panel-Reason svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.DocSearch-Feedback-Panel-Textarea { + width: 100%; + min-height: 64px; + resize: vertical; + box-sizing: border-box; + padding: 10px 12px; + border-radius: var(--docsearch-border-radius); + border: 1px solid var(--docsearch-subtle-color); + background: var(--docsearch-hit-background); + color: var(--docsearch-text-color); + font-family: inherit; + font-size: 1em; + line-height: 1.5; +} + +.DocSearch-Feedback-Panel-Textarea::placeholder { + color: var(--docsearch-muted-color); + opacity: 1; +} + +.DocSearch-Feedback-Panel-Textarea:focus { + outline: none; + border-color: var(--docsearch-highlight-color); +} + +.DocSearch-Feedback-Panel-CharCount { + margin: 0; + align-self: flex-end; + font-size: 0.8em; + color: var(--docsearch-muted-color); + font-variant-numeric: tabular-nums; +} + +.DocSearch-Feedback-Panel-Error { + margin: 0; + font-size: 0.85em; + color: var(--docsearch-error-color); +} + +.DocSearch-Feedback-Panel-Actions { + align-self: flex-end; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 1em; +} + +.DocSearch-Feedback-Panel-Submit, +.DocSearch-Feedback-Panel-Cancel { + display: flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 6px 15px; + border: none; + border-radius: var(--docsearch-border-radius); + font-family: inherit; + font-size: 0.9em; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.DocSearch-Feedback-Panel-Submit { + background: var(--docsearch-highlight-color); + color: #fff; +} + +.DocSearch-Feedback-Panel-Submit:disabled { + opacity: 0.6; + cursor: default; +} + +.DocSearch-Feedback-Panel-Submit:hover:not(:disabled) { + opacity: 0.9; +} + +.DocSearch-Feedback-Panel-Cancel { + background: var(--docsearch-modal-background); +} + +.DocSearch-Feedback-Panel-Cancel:hover { + background: var(--docsearch-subtle-color); +} + + .DocSearch-AskAiScreen-ActionButton { display: flex; align-items: center; diff --git a/packages/docsearch-css/src/sidepanel.css b/packages/docsearch-css/src/sidepanel.css index c88dbbc4..89d8aa95 100644 --- a/packages/docsearch-css/src/sidepanel.css +++ b/packages/docsearch-css/src/sidepanel.css @@ -672,14 +672,18 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover { color: var(--docsearch-muted-color); } -.DocSearch-Sidepanel .DocSearch-AskAiScreen-Actions { - margin-left: 0; +.DocSearch-Sidepanel .DocSearch-AskAiScreen-Actions-Controls { + align-self: flex-start; } .DocSearch-Sidepanel .DocSearch-AskAiScreen-CopyButton { order: -1; } +.DocSearch-Sidepanel .DocSearch-Feedback-Panel { + padding: 0.75em; +} + .DocSearch-Sidepanel .DocSearch-AskAiScreen-RelatedSources { padding-bottom: 1rem; } diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index b1356572..a55d004f 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -1,13 +1,14 @@ import type { UseChatHelpers } from '@ai-sdk/react'; -import React, { type JSX, useMemo, useState, useEffect } from 'react'; +import React, { type JSX, useMemo } from 'react'; import { AggregatedSearchBlock } from './AggregatedSearchBlock'; import type { AskAiScreenStateProps } from './AskAiScreenState'; +import { FeedbackActions } from './components/FeedbackActions'; import { ToolCall, type ToolCallTranslations } from './components/ToolCall'; import { AlertIcon, LoadingIcon } from './icons'; import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { StoredSearchPlugin } from './stored-searches'; -import type { InternalDocSearchHit, StoredAskAiState } from './types'; +import type { InternalDocSearchHit, OnAskAiFeedback, StoredAskAiState } from './types'; import { type AIMessage, type ToolCalls } from './types/AskiAi'; import { extractLinksFromMessage, getMessageContent, isThreadDepthError, isAIToolPart } from './utils/ai'; import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults'; @@ -27,6 +28,18 @@ export type AskAiScreenTranslations = Partial< likeButtonTitle: string; dislikeButtonTitle: string; thanksForFeedbackText: string; + // Negative feedback note panel + feedbackPanelTitle: string; + feedbackDetailsPlaceholder: string; + feedbackDisclaimerText: string; + feedbackSubmitButtonText: string; + feedbackCloseButtonTitle: string; + feedbackTagIncorrect: string; + feedbackTagNotWhatIAsked: string; + feedbackTagSlowOrBuggy: string; + feedbackTagStyleOrTone: string; + feedbackTagSafetyOrLegal: string; + feedbackTagOther: string; // Tool call texts /** * Text shown while assistant is performing search tool call. @@ -128,7 +141,7 @@ interface AskAiExchangeCardProps { translations: AskAiScreenTranslations; tools: ToolCalls; conversations: StoredSearchPlugin; - onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; + onFeedback?: OnAskAiFeedback; memoryEnabled?: boolean; } @@ -268,7 +281,7 @@ function AskAiExchangeCard({ {wasStopped &&

{stoppedStreamingText}

}
- ; - onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; -} - -export function AskAiScreenFooterActions({ - id, - showActions, - latestAssistantMessageContent, - translations, - conversations, - onFeedback, -}: AskAiScreenFooterActionsProps): JSX.Element | null { - // local state for feedback, initialised from stored conversations - const initialFeedback = React.useMemo(() => { - const message = conversations.getOne?.(id); - return message?.feedback ?? null; - }, [conversations, id]); - - const [feedback, setFeedback] = React.useState<'dislike' | 'like' | null>(initialFeedback); - const [saving, setSaving] = React.useState(false); - const [savingError, setSavingError] = React.useState(null); - - const handleFeedback = async (value: 'dislike' | 'like'): Promise => { - if (saving) return; - setSavingError(null); - setSaving(true); - try { - await onFeedback?.(id, value === 'like' ? 1 : 0); - setFeedback(value); - } catch (error) { - setSavingError(error as Error); - } finally { - setSaving(false); - } - }; - - const { - likeButtonTitle = 'Like', - dislikeButtonTitle = 'Dislike', - thanksForFeedbackText = 'Thanks for your feedback!', - } = translations; - - if (!showActions || !latestAssistantMessageContent) { - return null; - } - - return ( -
- {feedback === null ? ( - <> - {saving ? ( - - ) : ( - <> - handleFeedback('like')} /> - handleFeedback('dislike')} /> - - )} - {savingError && ( -

{savingError.message || 'An error occured'}

- )} - - ) : ( -

- {thanksForFeedbackText} -

- )} - navigator.clipboard.writeText(latestAssistantMessageContent)} - /> -
- ); -} - interface AskAiSourcesPanelProps { urlsToDisplay: Array<{ url: string; title?: string }>; relatedSourcesText?: string; @@ -508,129 +441,3 @@ function RelatedSourceIcon(): JSX.Element { ); } - -export function CopyButton({ - onClick, - translations, -}: { - onClick: () => void; - translations: AskAiScreenTranslations; -}): JSX.Element { - const { copyButtonTitle = 'Copy', copyButtonCopiedText = 'Copied!' } = translations; - - const [isCopied, setIsCopied] = useState(false); - - useEffect(() => { - if (isCopied) { - const timer = setTimeout(() => { - setIsCopied(false); - }, 1500); // reset after 1.5 seconds - return (): void => clearTimeout(timer); - } - return undefined; - }, [isCopied]); - - const handleClick = (): void => { - onClick(); - setIsCopied(true); - }; - - return ( - - ); -} - -export function LikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element { - return ( - - ); -} - -export function DislikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element { - return ( - - ); -} diff --git a/packages/docsearch-react/src/AskAiScreenState.tsx b/packages/docsearch-react/src/AskAiScreenState.tsx index df358a6c..b380f187 100644 --- a/packages/docsearch-react/src/AskAiScreenState.tsx +++ b/packages/docsearch-react/src/AskAiScreenState.tsx @@ -17,7 +17,13 @@ import { NoResultsScreen } from './NoResultsScreen'; import type { ResultsScreenTranslations } from './ResultsScreen'; import { ResultsScreen } from './ResultsScreen'; import type { StoredSearchPlugin } from './stored-searches'; -import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit, SuggestedQuestionHit } from './types'; +import type { + InternalDocSearchHit, + OnAskAiFeedback, + StoredAskAiState, + StoredDocSearchHit, + SuggestedQuestionHit, +} from './types'; import type { AIMessage, AskAiState, ToolCalls } from './types/AskiAi'; export type AskAiScreenStateTranslations = Partial<{ @@ -51,7 +57,7 @@ export interface AskAiScreenStateProps translations: AskAiScreenStateTranslations; getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; hasCollections: boolean; - onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; + onFeedback?: OnAskAiFeedback; askAiState: AskAiState; selectAskAiQuestion: (toggle: boolean, query: string) => void; suggestedQuestions: SuggestedQuestionHit[]; diff --git a/packages/docsearch-react/src/DocSearchAskAiModal.tsx b/packages/docsearch-react/src/DocSearchAskAiModal.tsx index 686327c0..e90f9fa1 100644 --- a/packages/docsearch-react/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-react/src/DocSearchAskAiModal.tsx @@ -20,7 +20,13 @@ import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery'; import { useSaveRecentSearch } from './hooks/useSaveRecentSearch'; import { useStoredDocSearches } from './hooks/useStoredDocSearches'; import type { NewConversationTranslations } from './NewConversationScreen'; -import type { DocSearchState, InternalDocSearchHit, StoredAskAiMessage, SuggestedQuestionHit } from './types'; +import type { + DocSearchState, + InternalDocSearchHit, + OnAskAiFeedback, + StoredAskAiMessage, + SuggestedQuestionHit, +} from './types'; import { type AskAiState } from './types/AskiAi'; import { useAskAi } from './useAskAi'; import { useSearchClient } from './useSearchClient'; @@ -263,10 +269,10 @@ export function DocSearchAskAiModal({ ); // feedback handler - const handleFeedbackSubmit = React.useCallback( - async (messageId: string, thumbs: 0 | 1): Promise => { + const handleFeedbackSubmit = React.useCallback( + async (messageId, feedback): Promise => { if (!askAiConfigurationId || !appId) return; - await sendFeedback(messageId, thumbs); + await sendFeedback(messageId, feedback); }, [askAiConfigurationId, appId, sendFeedback], ); diff --git a/packages/docsearch-react/src/Sidepanel/ConversationActions.tsx b/packages/docsearch-react/src/Sidepanel/ConversationActions.tsx deleted file mode 100644 index e8864a57..00000000 --- a/packages/docsearch-react/src/Sidepanel/ConversationActions.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { JSX } from 'react'; -import React from 'react'; - -import { CopyButton, DislikeButton, LikeButton, type AskAiScreenTranslations } from '../AskAiScreen'; -import { LoadingIcon } from '../icons'; -import type { StoredSearchPlugin } from '../stored-searches'; -import type { StoredAskAiState } from '../types'; - -interface ConversationActionsProps { - id: string; - showActions: boolean; - latestAssistantMessageContent: string | null; - translations: AskAiScreenTranslations; - conversations: StoredSearchPlugin; - onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; - isSidepanel?: boolean; -} - -export function ConversationActions({ - translations, - conversations, - id, - onFeedback, - latestAssistantMessageContent, - showActions, -}: ConversationActionsProps): JSX.Element | null { - const initialFeedback = React.useMemo(() => { - const message = conversations.getOne?.(id); - return message?.feedback ?? null; - }, [conversations, id]); - - const [feedback, setFeedback] = React.useState<'dislike' | 'like' | null>(initialFeedback); - const [saving, setSaving] = React.useState(false); - const [savingError, setSavingError] = React.useState(null); - - const handleFeedback = async (value: 'dislike' | 'like'): Promise => { - if (saving) return; - setSavingError(null); - setSaving(true); - try { - await onFeedback?.(id, value === 'like' ? 1 : 0); - setFeedback(value); - } catch (error) { - setSavingError(error as Error); - } finally { - setSaving(false); - } - }; - - const { - likeButtonTitle = 'Like', - dislikeButtonTitle = 'Dislike', - thanksForFeedbackText = 'Thanks for your feedback!', - } = translations; - - if (!showActions || !latestAssistantMessageContent) { - return null; - } - - return ( -
- navigator.clipboard.writeText(latestAssistantMessageContent)} - /> - {feedback === null ? ( - <> - {saving ? ( - - ) : ( - <> - handleFeedback('like')} /> - handleFeedback('dislike')} /> - - )} - {savingError && ( -

{savingError.message || 'An error occured'}

- )} - - ) : ( -

- {thanksForFeedbackText} -

- )} -
- ); -} diff --git a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx index b5218105..b6fa1203 100644 --- a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx +++ b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx @@ -3,17 +3,17 @@ import type { JSX } from 'react'; import React, { memo, useMemo } from 'react'; import { AskAiSourcesPanel, type Exchange } from '../AskAiScreen'; +import { FeedbackActions } from '../components/FeedbackActions'; import { ToolCall, type ToolCallTranslations } from '../components/ToolCall'; import { AlertIcon, LoadingIcon } from '../icons'; import { MemoizedMarkdown } from '../MemoizedMarkdown'; import type { StoredSearchPlugin } from '../stored-searches'; -import type { StoredAskAiState } from '../types'; +import type { OnAskAiFeedback, StoredAskAiState } from '../types'; import { type AIMessage, type ToolCalls } from '../types/AskiAi'; import { extractLinksFromMessage, getMessageContent, EMPTY_TOOLS, isAIToolPart } from '../utils/ai'; import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults'; import { AggregatedSearchBlock } from './AggregatedSearchBlock'; -import { ConversationActions } from './ConversationActions'; export type ConversationScreenTranslations = Partial< ToolCallTranslations & { @@ -57,6 +57,35 @@ export type ConversationScreenTranslations = Partial< * Message displayed after feedback action. **/ thanksForFeedbackText: string; + /** + * Title shown at the top of the negative feedback note panel. + **/ + feedbackPanelTitle: string; + /** + * Placeholder for the negative feedback details textarea. + **/ + feedbackDetailsPlaceholder: string; + /** + * Disclaimer shown inside the negative feedback note panel. + **/ + feedbackDisclaimerText: string; + /** + * Submit button text for the negative feedback note panel. + **/ + feedbackSubmitButtonText: string; + /** + * Accessible title for the negative feedback note panel close button. + **/ + feedbackCloseButtonTitle: string; + /** + * Reason chip labels for the negative feedback note panel. + **/ + feedbackTagIncorrect: string; + feedbackTagNotWhatIAsked: string; + feedbackTagSlowOrBuggy: string; + feedbackTagStyleOrTone: string; + feedbackTagSafetyOrLegal: string; + feedbackTagOther: string; /** * Error title shown if there is an error while chatting. */ @@ -69,7 +98,7 @@ export type ConversationScreenProps = { conversations: StoredSearchPlugin; translations?: ConversationScreenTranslations; status: UseChatHelpers['status']; - handleFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; + handleFeedback?: OnAskAiFeedback; streamError?: Error; memoryEnabled?: boolean; tools?: ToolCalls; @@ -231,7 +260,8 @@ const ConversationExchange = React.forwardRef
- { + return { + add: vi.fn(), + remove: vi.fn(), + getAll: vi.fn(() => []), + getOne: vi.fn(() => stored), + addFeedback: vi.fn(), + }; +} + +function renderComponent(overrides: Partial> = {}): { + onFeedback: ReturnType; +} { + const onFeedback = vi.fn().mockResolvedValue(undefined); + render( + , + ); + return { onFeedback }; +} + +describe('FeedbackActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('does not render when showActions is false', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('submits positive feedback immediately on thumbs up', async () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Like')); + + await waitFor(() => expect(onFeedback).toHaveBeenCalledWith('message-1', { thumbs: 1 })); + expect(onFeedback).toHaveBeenCalledTimes(1); + expect(screen.getByText('Thanks for your feedback!')).toBeInTheDocument(); + }); + + it('opens the note panel on thumbs down without sending feedback yet', () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + + expect(screen.getByText('What went wrong? (optional)')).toBeInTheDocument(); + expect(onFeedback).not.toHaveBeenCalled(); + }); + + it('submits negative feedback with tags and notes', async () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + fireEvent.click(screen.getByText('Incorrect or incomplete')); + fireEvent.change(screen.getByPlaceholderText('Share some details...'), { + target: { value: ' it was wrong ' }, + }); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => + expect(onFeedback).toHaveBeenCalledWith('message-1', { + thumbs: 0, + tags: ['incorrect'], + notes: 'it was wrong', + }), + ); + expect(screen.getByText('Thanks for your feedback!')).toBeInTheDocument(); + }); + + it('submits multiple selected tags as an array', async () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + fireEvent.click(screen.getByText('Incorrect or incomplete')); + fireEvent.click(screen.getByText('Other')); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => + expect(onFeedback).toHaveBeenCalledWith('message-1', { + thumbs: 0, + tags: ['incorrect', 'other'], + notes: undefined, + }), + ); + }); + + it('allows submitting negative feedback without tags or notes', async () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => + expect(onFeedback).toHaveBeenCalledWith('message-1', { + thumbs: 0, + tags: undefined, + notes: undefined, + }), + ); + }); + + it('toggles a selected tag off when clicked again', async () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + const tag = screen.getByText('Incorrect or incomplete'); + fireEvent.click(tag); + fireEvent.click(tag); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => + expect(onFeedback).toHaveBeenCalledWith('message-1', { + thumbs: 0, + tags: undefined, + notes: undefined, + }), + ); + }); + + it('closing the note panel returns to actions without recording feedback', () => { + const { onFeedback } = renderComponent(); + + fireEvent.click(screen.getByTitle('Dislike')); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByText('What went wrong? (optional)')).not.toBeInTheDocument(); + expect(screen.getByTitle('Like')).toBeInTheDocument(); + expect(onFeedback).not.toHaveBeenCalled(); + }); + + it('keeps the panel open and shows an error when submission fails', async () => { + const onFeedback = vi.fn().mockRejectedValue(new Error('Network down')); + render( + , + ); + + fireEvent.click(screen.getByTitle('Dislike')); + fireEvent.click(screen.getByText('Submit')); + + expect(await screen.findByText('Network down')).toBeInTheDocument(); + expect(screen.getByText('What went wrong? (optional)')).toBeInTheDocument(); + }); + + it('restores the thanks view when feedback was already recorded', () => { + const stored = { + id: 'message-1', + feedback: 'dislike', + } as unknown as StoredAskAiMessage; + renderComponent({ conversations: createConversations(stored) }); + + expect(screen.getByText('Thanks for your feedback!')).toBeInTheDocument(); + expect(screen.queryByTitle('Like')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/docsearch-react/src/askai.ts b/packages/docsearch-react/src/askai.ts index 4f9f6bc5..64d89585 100644 --- a/packages/docsearch-react/src/askai.ts +++ b/packages/docsearch-react/src/askai.ts @@ -39,6 +39,8 @@ export const postAgentStudioFeedback = ({ appId, apiKey, abortSignal, + notes, + tags, }: { agentId: string; vote: 0 | 1; @@ -46,6 +48,8 @@ export const postAgentStudioFeedback = ({ appId: string; apiKey: string; abortSignal: AbortSignal; + notes?: string; + tags?: string[]; }): Promise => { const headers = new Headers(); headers.set('x-algolia-application-id', appId); @@ -60,6 +64,8 @@ export const postAgentStudioFeedback = ({ messageId, agentId, vote, + ...(notes ? { notes } : {}), + ...(tags && tags.length > 0 ? { tags } : {}), }), headers, signal: abortSignal, diff --git a/packages/docsearch-react/src/components/FeedbackActions.tsx b/packages/docsearch-react/src/components/FeedbackActions.tsx new file mode 100644 index 00000000..9da70269 --- /dev/null +++ b/packages/docsearch-react/src/components/FeedbackActions.tsx @@ -0,0 +1,434 @@ +import React, { type JSX, useEffect, useState } from 'react'; + +import { LoadingIcon } from '../icons'; +import type { StoredSearchPlugin } from '../stored-searches'; +import type { AskAiFeedbackReason, OnAskAiFeedback, StoredAskAiState } from '../types'; + +export type FeedbackActionsTranslations = Partial<{ + /** Tooltip/aria-label for the button that copies the assistant's answer to the clipboard. */ + copyButtonTitle: string; + /** Transient confirmation label shown on the copy button right after a successful copy. */ + copyButtonCopiedText: string; + /** Tooltip/aria-label for the thumbs-up (positive feedback) button. */ + likeButtonTitle: string; + /** Tooltip/aria-label for the thumbs-down (negative feedback) button that opens the notes form. */ + dislikeButtonTitle: string; + /** Confirmation message shown after feedback has been submitted. */ + thanksForFeedbackText: string; + /** Heading displayed at the top of the negative feedback form (e.g. "What went wrong?"). */ + feedbackPanelTitle: string; + /** Placeholder text for the free-form notes textarea in the negative feedback form. */ + feedbackDetailsPlaceholder: string; + /** Label for the button that submits the negative feedback form. */ + feedbackSubmitButtonText: string; + /** Label for the button that cancels/dismisses the negative feedback form. */ + feedbackCancelButtonText: string; + /** Tooltip/aria-label for the button that closes/cancels the negative feedback form. */ + feedbackCloseButtonTitle: string; + /** Label for the "Incorrect or incomplete" tag. */ + feedbackTagIncorrect: string; + /** Label for the "Not what I asked for" tag. */ + feedbackTagNotWhatIAsked: string; + /** Label for the "Slow or buggy" tag. */ + feedbackTagSlowOrBuggy: string; + /** Label for the "Style or tone" tag. */ + feedbackTagStyleOrTone: string; + /** Label for the "Safety or legal concern" tag. */ + feedbackTagSafetyOrLegal: string; + /** Label for the "Other" tag. */ + feedbackTagOther: string; +}>; + +type TagTranslationKey = Extract; + +type FeedbackReasonOption = { + value: AskAiFeedbackReason; + translationKey: TagTranslationKey; + defaultLabel: string; +}; + +// Hoisted at module level so the array identity is stable across renders. +const FEEDBACK_REASONS: FeedbackReasonOption[] = [ + { + value: 'incorrect', + translationKey: 'feedbackTagIncorrect', + defaultLabel: 'Incorrect or incomplete', + }, + { + value: 'not_what_i_asked', + translationKey: 'feedbackTagNotWhatIAsked', + defaultLabel: 'Not what I asked for', + }, + { + value: 'slow_or_buggy', + translationKey: 'feedbackTagSlowOrBuggy', + defaultLabel: 'Slow or buggy', + }, + { + value: 'style_or_tone', + translationKey: 'feedbackTagStyleOrTone', + defaultLabel: 'Style or tone', + }, + { + value: 'safety_or_legal', + translationKey: 'feedbackTagSafetyOrLegal', + defaultLabel: 'Safety or legal concern', + }, + { + value: 'other', + translationKey: 'feedbackTagOther', + defaultLabel: 'Other', + }, +]; + +// Maximum number of characters allowed in the feedback notes field. +const MAX_NOTES_LENGTH = 1000; + +type FeedbackView = 'actions' | 'note' | 'thanks'; + +interface FeedbackActionsProps { + id: string; + showActions: boolean; + latestAssistantMessageContent: string | null; + translations: FeedbackActionsTranslations; + conversations: StoredSearchPlugin; + onFeedback?: OnAskAiFeedback; + /** When rendered in the Sidepanel the copy button is ordered first. */ + isSidepanel?: boolean; +} + +export function FeedbackActions({ + id, + showActions, + latestAssistantMessageContent, + translations, + conversations, + onFeedback, + isSidepanel = false, +}: FeedbackActionsProps): JSX.Element | null { + // Derive the initial view during render (no effect needed): if feedback was + // already recorded for this message, jump straight to the "thanks" state. + const [view, setView] = useState(() => { + const message = conversations.getOne?.(id); + return message?.feedback ? 'thanks' : 'actions'; + }); + const [tags, setTags] = useState([]); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + const [savingError, setSavingError] = useState(null); + + const { + likeButtonTitle = 'Like', + dislikeButtonTitle = 'Dislike', + thanksForFeedbackText = 'Thanks for your feedback!', + } = translations; + + const handleLike = async (): Promise => { + if (saving) return; + setSavingError(null); + setSaving(true); + try { + await onFeedback?.(id, { thumbs: 1 }); + setView('thanks'); + } catch (error) { + setSavingError(error as Error); + } finally { + setSaving(false); + } + }; + + const handleDislike = (): void => { + setSavingError(null); + setView('note'); + }; + + const handleToggleTag = (value: AskAiFeedbackReason): void => { + setTags((prev) => (prev.includes(value) ? prev.filter((tag) => tag !== value) : [...prev, value])); + }; + + const handleSubmitNote = async (): Promise => { + if (saving) return; + setSavingError(null); + setSaving(true); + try { + await onFeedback?.(id, { + thumbs: 0, + tags: tags.length > 0 ? tags : undefined, + notes: notes.trim() || undefined, + }); + setView('thanks'); + } catch (error) { + setSavingError(error as Error); + } finally { + setSaving(false); + } + }; + + const handleCloseNote = (): void => { + setTags([]); + setNotes(''); + setSavingError(null); + setView('actions'); + }; + + if (!showActions || !latestAssistantMessageContent) { + return null; + } + + const copyButton = ( + navigator.clipboard.writeText(latestAssistantMessageContent)} + /> + ); + + return ( +
+
+ {isSidepanel ? copyButton : null} + + {view === 'thanks' ? ( +

+ {thanksForFeedbackText} +

+ ) : ( + <> + {saving && view === 'actions' ? ( + + ) : ( + <> + + + + )} + {savingError && view === 'actions' ? ( +

{savingError.message || 'An error occured'}

+ ) : null} + + )} + + {isSidepanel ? null : copyButton} +
+ + {view === 'note' ? ( + + ) : null} +
+ ); +} + +interface NegativeFeedbackPanelProps { + translations: FeedbackActionsTranslations; + tags: AskAiFeedbackReason[]; + notes: string; + saving: boolean; + savingError: Error | null; + onToggleTag: (tag: AskAiFeedbackReason) => void; + onNotesChange: (notes: string) => void; + onSubmit: () => void; + onClose: () => void; +} + +function NegativeFeedbackPanel({ + translations, + tags, + notes, + saving, + savingError, + onToggleTag, + onNotesChange, + onSubmit, + onClose, +}: NegativeFeedbackPanelProps): JSX.Element { + const { + feedbackPanelTitle = 'What went wrong? (optional)', + feedbackDetailsPlaceholder = 'Share some details...', + feedbackSubmitButtonText = 'Submit', + feedbackCancelButtonText = 'Cancel', + } = translations; + + return ( +
+
+

{feedbackPanelTitle}

+
+ +
+ {FEEDBACK_REASONS.map((reason) => { + const isSelected = tags.includes(reason.value); + return ( + + ); + })} +
+ +