1
0
Fork 0

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:
Paul Jankowski 2026-06-02 08:37:30 -04:00 committed by GitHub
parent 144250a62c
commit e35349b3a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1028 additions and 332 deletions

View file

@ -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...',

View file

@ -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",

View file

@ -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);

View file

@ -101,7 +101,7 @@ function App(): JSX.Element {
</div>
<DocSearchSidepanel
assistantId="askAIDemo"
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"

View file

@ -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>

View file

@ -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}

View file

@ -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={{

View file

@ -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;

View file

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

View file

@ -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>
);
}

View file

@ -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[];

View file

@ -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],
);

View file

@ -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>
);
}

View file

@ -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}

View 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();
});
});

View file

@ -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,

View 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>
);
}

View file

@ -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);
},

View 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>;

View file

@ -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 & {

View file

@ -1,5 +1,6 @@
export * from './DocSearchHit';
export * from './DocSearchState';
export * from './Feedback';
export * from './DocSearchTheme';
export * from './InternalDocSearchHit';
export * from './KeyboardShortcuts';

View file

@ -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],
);