feat(askai): Feedback notes and tags (#2890)
* feat(askai): add Agent Studio memory support * refactor(askai): remove Ask AI transport abstraction * feat(askai): Feedback notes and tags * fix: bump css bundle size limit * move feedback actions to components
This commit is contained in:
parent
144250a62c
commit
e35349b3a9
22 changed files with 1028 additions and 332 deletions
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function App(): JSX.Element {
|
|||
</div>
|
||||
|
||||
<DocSearchSidepanel
|
||||
assistantId="askAIDemo"
|
||||
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
|
||||
indexName="docsearch"
|
||||
appId="PMZUYBQDAK"
|
||||
apiKey="24b09689d5b4223813d9b8e48563c8f6"
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ export default function Composable(): JSX.Element {
|
|||
appId="PMZUYBQDAK"
|
||||
apiKey="24b09689d5b4223813d9b8e48563c8f6"
|
||||
askAi={{
|
||||
assistantId: 'askAIDemo',
|
||||
searchParameters: {
|
||||
facetFilters: ['language:en'],
|
||||
},
|
||||
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
|
||||
}}
|
||||
/>
|
||||
</DocSearch>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StoredAskAiState>;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
onFeedback?: OnAskAiFeedback;
|
||||
memoryEnabled?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +281,7 @@ function AskAiExchangeCard({
|
|||
{wasStopped && <p className="DocSearck-AskAiScreen-MessageContent-Stopped">{stoppedStreamingText}</p>}
|
||||
</div>
|
||||
<div className="DocSearch-AskAiScreen-Answer-Footer">
|
||||
<AskAiScreenFooterActions
|
||||
<FeedbackActions
|
||||
id={messageId}
|
||||
showActions={showActions}
|
||||
latestAssistantMessageContent={assistantContent?.text || null}
|
||||
|
|
@ -287,86 +300,6 @@ function AskAiExchangeCard({
|
|||
);
|
||||
}
|
||||
|
||||
interface AskAiScreenFooterActionsProps {
|
||||
id: string;
|
||||
showActions: boolean;
|
||||
latestAssistantMessageContent: string | null;
|
||||
translations: AskAiScreenTranslations;
|
||||
conversations: StoredSearchPlugin<StoredAskAiState>;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
}
|
||||
|
||||
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<Error | null>(null);
|
||||
|
||||
const handleFeedback = async (value: 'dislike' | 'like'): Promise<void> => {
|
||||
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 (
|
||||
<div className="DocSearch-AskAiScreen-Actions">
|
||||
{feedback === null ? (
|
||||
<>
|
||||
{saving ? (
|
||||
<LoadingIcon className="DocSearch-AskAiScreen-SmallerLoadingIcon" />
|
||||
) : (
|
||||
<>
|
||||
<LikeButton title={likeButtonTitle} onClick={() => handleFeedback('like')} />
|
||||
<DislikeButton title={dislikeButtonTitle} onClick={() => handleFeedback('dislike')} />
|
||||
</>
|
||||
)}
|
||||
{savingError && (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText">{savingError.message || 'An error occured'}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText DocSearch-AskAiScreen-FeedbackText--visible">
|
||||
{thanksForFeedbackText}
|
||||
</p>
|
||||
)}
|
||||
<CopyButton
|
||||
translations={translations}
|
||||
onClick={() => navigator.clipboard.writeText(latestAssistantMessageContent)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AskAiSourcesPanelProps {
|
||||
urlsToDisplay: Array<{ url: string; title?: string }>;
|
||||
relatedSourcesText?: string;
|
||||
|
|
@ -508,129 +441,3 @@ function RelatedSourceIcon(): JSX.Element {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-CopyButton ${
|
||||
isCopied ? 'DocSearch-AskAiScreen-CopyButton--copied' : ''
|
||||
}`}
|
||||
disabled={isCopied} // disable button briefly after copy
|
||||
title={isCopied ? copyButtonCopiedText : copyButtonTitle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isCopied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-check-icon lucide-check"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-copy-icon lucide-copy"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-LikeButton"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-thumbs-up-icon lucide-thumbs-up"
|
||||
>
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DislikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-DislikeButton"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-thumbs-down-icon lucide-thumbs-down"
|
||||
>
|
||||
<path d="M17 14V2" />
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TItem extends BaseItem>
|
|||
translations: AskAiScreenStateTranslations;
|
||||
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
|
||||
hasCollections: boolean;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
onFeedback?: OnAskAiFeedback;
|
||||
askAiState: AskAiState;
|
||||
selectAskAiQuestion: (toggle: boolean, query: string) => void;
|
||||
suggestedQuestions: SuggestedQuestionHit[];
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
const handleFeedbackSubmit = React.useCallback<OnAskAiFeedback>(
|
||||
async (messageId, feedback): Promise<void> => {
|
||||
if (!askAiConfigurationId || !appId) return;
|
||||
await sendFeedback(messageId, thumbs);
|
||||
await sendFeedback(messageId, feedback);
|
||||
},
|
||||
[askAiConfigurationId, appId, sendFeedback],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<StoredAskAiState>;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
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<Error | null>(null);
|
||||
|
||||
const handleFeedback = async (value: 'dislike' | 'like'): Promise<void> => {
|
||||
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 (
|
||||
<div className="DocSearch-AskAiScreen-Actions">
|
||||
<CopyButton
|
||||
translations={translations}
|
||||
onClick={() => navigator.clipboard.writeText(latestAssistantMessageContent)}
|
||||
/>
|
||||
{feedback === null ? (
|
||||
<>
|
||||
{saving ? (
|
||||
<LoadingIcon className="DocSearch-AskAiScreen-SmallerLoadingIcon" />
|
||||
) : (
|
||||
<>
|
||||
<LikeButton title={likeButtonTitle} onClick={() => handleFeedback('like')} />
|
||||
<DislikeButton title={dislikeButtonTitle} onClick={() => handleFeedback('dislike')} />
|
||||
</>
|
||||
)}
|
||||
{savingError && (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText">{savingError.message || 'An error occured'}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText DocSearch-AskAiScreen-FeedbackText--visible">
|
||||
{thanksForFeedbackText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<StoredAskAiState>;
|
||||
translations?: ConversationScreenTranslations;
|
||||
status: UseChatHelpers<AIMessage>['status'];
|
||||
handleFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
handleFeedback?: OnAskAiFeedback;
|
||||
streamError?: Error;
|
||||
memoryEnabled?: boolean;
|
||||
tools?: ToolCalls;
|
||||
|
|
@ -231,7 +260,8 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
|
|||
</div>
|
||||
|
||||
<div className="DocSearch-AskAiScreen-Answer-Footer">
|
||||
<ConversationActions
|
||||
<FeedbackActions
|
||||
isSidepanel={true}
|
||||
id={messageId}
|
||||
showActions={showActions}
|
||||
latestAssistantMessageContent={assistantContent?.text || null}
|
||||
|
|
|
|||
199
packages/docsearch-react/src/__tests__/FeedbackActions.test.tsx
Normal file
199
packages/docsearch-react/src/__tests__/FeedbackActions.test.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { FeedbackActions, type FeedbackActionsTranslations } from '../components/FeedbackActions';
|
||||
import type { StoredSearchPlugin } from '../stored-searches';
|
||||
import type { StoredAskAiMessage, StoredAskAiState } from '../types';
|
||||
|
||||
const TRANSLATIONS: FeedbackActionsTranslations = {
|
||||
likeButtonTitle: 'Like',
|
||||
dislikeButtonTitle: 'Dislike',
|
||||
thanksForFeedbackText: 'Thanks for your feedback!',
|
||||
feedbackPanelTitle: 'What went wrong? (optional)',
|
||||
feedbackSubmitButtonText: 'Submit',
|
||||
feedbackTagIncorrect: 'Incorrect or incomplete',
|
||||
};
|
||||
|
||||
function createConversations(stored?: StoredAskAiMessage): StoredSearchPlugin<StoredAskAiState> {
|
||||
return {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getAll: vi.fn(() => []),
|
||||
getOne: vi.fn(() => stored),
|
||||
addFeedback: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderComponent(overrides: Partial<React.ComponentProps<typeof FeedbackActions>> = {}): {
|
||||
onFeedback: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const onFeedback = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<FeedbackActions
|
||||
showActions={true}
|
||||
id="message-1"
|
||||
latestAssistantMessageContent="Some answer"
|
||||
translations={TRANSLATIONS}
|
||||
conversations={createConversations()}
|
||||
onFeedback={onFeedback}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onFeedback };
|
||||
}
|
||||
|
||||
describe('FeedbackActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not render when showActions is false', () => {
|
||||
const { container } = render(
|
||||
<FeedbackActions
|
||||
id="message-1"
|
||||
showActions={false}
|
||||
latestAssistantMessageContent="Some answer"
|
||||
translations={TRANSLATIONS}
|
||||
conversations={createConversations()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FeedbackActions
|
||||
showActions={true}
|
||||
id="message-1"
|
||||
latestAssistantMessageContent="Some answer"
|
||||
translations={TRANSLATIONS}
|
||||
conversations={createConversations()}
|
||||
onFeedback={onFeedback}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Response> => {
|
||||
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,
|
||||
|
|
|
|||
434
packages/docsearch-react/src/components/FeedbackActions.tsx
Normal file
434
packages/docsearch-react/src/components/FeedbackActions.tsx
Normal file
|
|
@ -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<keyof FeedbackActionsTranslations, `feedbackTag${string}`>;
|
||||
|
||||
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<StoredAskAiState>;
|
||||
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<FeedbackView>(() => {
|
||||
const message = conversations.getOne?.(id);
|
||||
return message?.feedback ? 'thanks' : 'actions';
|
||||
});
|
||||
const [tags, setTags] = useState<AskAiFeedbackReason[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingError, setSavingError] = useState<Error | null>(null);
|
||||
|
||||
const {
|
||||
likeButtonTitle = 'Like',
|
||||
dislikeButtonTitle = 'Dislike',
|
||||
thanksForFeedbackText = 'Thanks for your feedback!',
|
||||
} = translations;
|
||||
|
||||
const handleLike = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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 = (
|
||||
<CopyButton
|
||||
translations={translations}
|
||||
onClick={() => navigator.clipboard.writeText(latestAssistantMessageContent)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="DocSearch-AskAiScreen-Actions">
|
||||
<div className="DocSearch-AskAiScreen-Actions-Controls">
|
||||
{isSidepanel ? copyButton : null}
|
||||
|
||||
{view === 'thanks' ? (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText DocSearch-AskAiScreen-FeedbackText--visible">
|
||||
{thanksForFeedbackText}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{saving && view === 'actions' ? (
|
||||
<LoadingIcon className="DocSearch-AskAiScreen-SmallerLoadingIcon" />
|
||||
) : (
|
||||
<>
|
||||
<LikeButton title={likeButtonTitle} onClick={handleLike} />
|
||||
<DislikeButton title={dislikeButtonTitle} onClick={handleDislike} />
|
||||
</>
|
||||
)}
|
||||
{savingError && view === 'actions' ? (
|
||||
<p className="DocSearch-AskAiScreen-FeedbackText">{savingError.message || 'An error occured'}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSidepanel ? null : copyButton}
|
||||
</div>
|
||||
|
||||
{view === 'note' ? (
|
||||
<NegativeFeedbackPanel
|
||||
translations={translations}
|
||||
tags={tags}
|
||||
notes={notes}
|
||||
saving={saving}
|
||||
savingError={savingError}
|
||||
onToggleTag={handleToggleTag}
|
||||
onNotesChange={setNotes}
|
||||
onSubmit={handleSubmitNote}
|
||||
onClose={handleCloseNote}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="DocSearch-Feedback-Panel" role="group" aria-label={feedbackPanelTitle}>
|
||||
<div className="DocSearch-Feedback-Panel-Header">
|
||||
<p className="DocSearch-Feedback-Panel-Title">{feedbackPanelTitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="DocSearch-Feedback-Panel-Reasons">
|
||||
{FEEDBACK_REASONS.map((reason) => {
|
||||
const isSelected = tags.includes(reason.value);
|
||||
return (
|
||||
<button
|
||||
key={reason.value}
|
||||
type="button"
|
||||
className={`DocSearch-Feedback-Panel-Reason${
|
||||
isSelected ? ' DocSearch-Feedback-Panel-Reason--selected' : ''
|
||||
}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onToggleTag(reason.value)}
|
||||
>
|
||||
<span>{translations[reason.translationKey] ?? reason.defaultLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="DocSearch-Feedback-Panel-Textarea"
|
||||
placeholder={feedbackDetailsPlaceholder}
|
||||
value={notes}
|
||||
rows={3}
|
||||
maxLength={MAX_NOTES_LENGTH}
|
||||
onChange={(event) => onNotesChange(event.target.value.slice(0, MAX_NOTES_LENGTH))}
|
||||
/>
|
||||
|
||||
<p className="DocSearch-Feedback-Panel-CharCount" aria-live="polite">
|
||||
{notes.length}/{MAX_NOTES_LENGTH}
|
||||
</p>
|
||||
|
||||
{savingError ? (
|
||||
<p className="DocSearch-Feedback-Panel-Error">{savingError.message || 'An error occured'}</p>
|
||||
) : null}
|
||||
|
||||
<div className="DocSearch-Feedback-Panel-Actions">
|
||||
<button type="button" className="DocSearch-Feedback-Panel-Cancel" onClick={onClose}>
|
||||
{feedbackCancelButtonText}
|
||||
</button>
|
||||
<button type="button" className="DocSearch-Feedback-Panel-Submit" disabled={saving} onClick={onSubmit}>
|
||||
{saving ? <LoadingIcon className="DocSearch-AskAiScreen-SmallerLoadingIcon" /> : feedbackSubmitButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
onClick,
|
||||
translations,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
translations: FeedbackActionsTranslations;
|
||||
}): 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-CopyButton ${
|
||||
isCopied ? 'DocSearch-AskAiScreen-CopyButton--copied' : ''
|
||||
}`}
|
||||
disabled={isCopied} // disable button briefly after copy
|
||||
title={isCopied ? copyButtonCopiedText : copyButtonTitle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isCopied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-LikeButton"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DislikeButton({ title, onClick }: { title: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-DislikeButton"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17 14V2" />
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,17 @@
|
|||
import type { DocSearchHit, StoredAskAiState, StoredDocSearchHit, StoredAskAiMessage } from './types';
|
||||
import type {
|
||||
AskAiFeedbackReason,
|
||||
DocSearchHit,
|
||||
StoredAskAiState,
|
||||
StoredDocSearchHit,
|
||||
StoredAskAiMessage,
|
||||
} from './types';
|
||||
import { createStorage } from './utils/storage';
|
||||
|
||||
export type StoredFeedbackDetails = {
|
||||
tags?: AskAiFeedbackReason[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
type CreateStoredSearchesOptions = {
|
||||
key: string;
|
||||
limit?: number;
|
||||
|
|
@ -10,7 +21,7 @@ export type StoredSearchPlugin<TItem> = {
|
|||
add: (item: TItem) => void;
|
||||
remove: (item: TItem) => void;
|
||||
getAll: () => TItem[];
|
||||
addFeedback?: (messageId: string, feedback: 'dislike' | 'like') => void;
|
||||
addFeedback?: (messageId: string, feedback: 'dislike' | 'like', details?: StoredFeedbackDetails) => void;
|
||||
getOne?: (messageId: string) => StoredAskAiMessage | undefined;
|
||||
getConversation?: (messageId: string) => TItem | undefined;
|
||||
};
|
||||
|
|
@ -72,14 +83,16 @@ export function createStoredConversations<TItem extends StoredAskAiState>({
|
|||
storage.setItem(items);
|
||||
},
|
||||
/** Record feedback (like/dislike) for a given message id within stored conversations. */
|
||||
addFeedback(messageId: string, feedback: 'dislike' | 'like'): void {
|
||||
addFeedback(messageId: string, feedback: 'dislike' | 'like', details?: StoredFeedbackDetails): void {
|
||||
const conv = items.find((c) => c.messages?.some((m) => m.id === messageId));
|
||||
if (!conv || !conv.messages) return;
|
||||
|
||||
const msg = conv.messages.find((m) => m.id === messageId);
|
||||
const msg = conv.messages.find((m) => m.id === messageId) as StoredAskAiMessage | undefined;
|
||||
if (!msg) return;
|
||||
|
||||
(msg as any).feedback = feedback;
|
||||
msg.feedback = feedback;
|
||||
msg.feedbackTags = details?.tags;
|
||||
msg.feedbackNotes = details?.notes;
|
||||
|
||||
storage.setItem(items);
|
||||
},
|
||||
|
|
|
|||
29
packages/docsearch-react/src/types/Feedback.ts
Normal file
29
packages/docsearch-react/src/types/Feedback.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Categories a user can select when submitting negative (thumbs down) feedback
|
||||
* on an AI chat message.
|
||||
*/
|
||||
export type AskAiFeedbackReason =
|
||||
| 'incorrect'
|
||||
| 'not_what_i_asked'
|
||||
| 'other'
|
||||
| 'safety_or_legal'
|
||||
| 'slow_or_buggy'
|
||||
| 'style_or_tone';
|
||||
|
||||
/**
|
||||
* Payload sent when a user submits feedback on an AI chat message.
|
||||
* `tags` and `notes` are only present for negative feedback.
|
||||
*/
|
||||
export type AskAiFeedbackPayload = {
|
||||
/** 1 for a thumbs up, 0 for a thumbs down. */
|
||||
thumbs: 0 | 1;
|
||||
/** Optional reason categories, only collected for negative feedback. */
|
||||
tags?: AskAiFeedbackReason[];
|
||||
/** Optional free-text note, only collected for negative feedback. */
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback invoked when a user submits feedback on an AI chat message.
|
||||
*/
|
||||
export type OnAskAiFeedback = (messageId: string, feedback: AskAiFeedbackPayload) => Promise<void>;
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
import type { AIMessage } from './AskiAi';
|
||||
import type { DocSearchHit } from './DocSearchHit';
|
||||
import type { AskAiFeedbackReason } from './Feedback';
|
||||
|
||||
export type StoredDocSearchHit = Omit<DocSearchHit, '_highlightResult' | '_snippetResult'>;
|
||||
|
||||
export type StoredAskAiMessage = AIMessage & {
|
||||
/** Optional user feedback on this assistant message. */
|
||||
feedback?: 'dislike' | 'like';
|
||||
/** Optional reason categories submitted with negative feedback. */
|
||||
feedbackTags?: AskAiFeedbackReason[];
|
||||
/** Optional free-text note submitted with negative feedback. */
|
||||
feedbackNotes?: string;
|
||||
};
|
||||
|
||||
export type StoredAskAiState = StoredDocSearchHit & {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './DocSearchHit';
|
||||
export * from './DocSearchState';
|
||||
export * from './Feedback';
|
||||
export * from './DocSearchTheme';
|
||||
export * from './InternalDocSearchHit';
|
||||
export * from './KeyboardShortcuts';
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Exchange } from './AskAiScreen';
|
|||
import type { StoredSearchPlugin } from './stored-searches';
|
||||
import { createStoredConversations } from './stored-searches';
|
||||
import { type AIMessage, type ToolCalls } from './types/AskiAi';
|
||||
import type { OnAskAiFeedback } from './types/Feedback';
|
||||
import { EMPTY_TOOLS } from './utils/ai';
|
||||
|
||||
import type { AgentStudioSearchParameters, Memory, StoredAskAiState } from '.';
|
||||
|
|
@ -35,7 +36,7 @@ type UseAskAiReturn = {
|
|||
isStreaming: boolean;
|
||||
exchanges: Exchange[];
|
||||
conversations: StoredSearchPlugin<StoredAskAiState>;
|
||||
sendFeedback: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
sendFeedback: OnAskAiFeedback;
|
||||
};
|
||||
|
||||
type UseAskAi = (params: UseAskAiParams) => UseAskAiReturn;
|
||||
|
|
@ -131,8 +132,8 @@ export const useAskAi: UseAskAi = ({
|
|||
}),
|
||||
).current;
|
||||
|
||||
const sendFeedback = useCallback(
|
||||
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
|
||||
const sendFeedback = useCallback<OnAskAiFeedback>(
|
||||
async (messageId, { thumbs, tags, notes }): Promise<void> => {
|
||||
if (!assistantId) return;
|
||||
|
||||
const res = await postAgentStudioFeedback({
|
||||
|
|
@ -142,10 +143,12 @@ export const useAskAi: UseAskAi = ({
|
|||
appId,
|
||||
apiKey,
|
||||
abortSignal: abortControllerRef.current.signal,
|
||||
notes,
|
||||
tags,
|
||||
});
|
||||
|
||||
if (res.status >= 300) throw new Error('Failed, try again later.');
|
||||
conversations.addFeedback?.(messageId, thumbs === 1 ? 'like' : 'dislike');
|
||||
conversations.addFeedback?.(messageId, thumbs === 1 ? 'like' : 'dislike', { tags, notes });
|
||||
},
|
||||
[assistantId, appId, apiKey, conversations],
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue