Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ba8a7ef
Add AskAI message feedback endpoint
reakaleek Dec 1, 2025
4ce3f48
Adjust otel naming
reakaleek Dec 1, 2025
1e52f45
Add UI onclick handler and other adjustments
reakaleek Dec 1, 2025
264ac88
Add log sanitization
reakaleek Dec 1, 2025
2d08faa
Use spans and create tests
reakaleek Dec 1, 2025
25eb256
Remove all control characters as suggested by CodeQL
reakaleek Dec 1, 2025
7926de7
Validate input
reakaleek Dec 1, 2025
4df1e5c
Refactor
reakaleek Dec 1, 2025
d0883cf
Persist reaction selection
reakaleek Dec 1, 2025
3dadebf
Add debounce if user quickly changes selection
reakaleek Dec 1, 2025
b48be34
Fix other CodeQL errors regarind log sanitization
reakaleek Dec 1, 2025
c2b7c3c
Fix formatting
reakaleek Dec 1, 2025
79e4117
Fix test
reakaleek Dec 1, 2025
5de5770
Dispose correctly
reakaleek Dec 1, 2025
a112b98
Change scope of services
reakaleek Dec 1, 2025
6eea0cb
Potential fix for pull request finding 'Missing Dispose call on local…
reakaleek Dec 1, 2025
b6b2cbf
Potential fix for pull request finding 'Missing Dispose call on local…
reakaleek Dec 1, 2025
5f6a852
Format
reakaleek Dec 1, 2025
07ff6e6
Fix logging format for conversation_id in AgentBuilder
reakaleek Dec 1, 2025
01cd487
Fix tests
reakaleek Dec 1, 2025
ce47e59
Potential fix for code scanning alert no. 38: Log entries created fro…
reakaleek Dec 1, 2025
e562c5c
Re-add fast path for common cases to avoid string allocation
reakaleek Dec 1, 2025
d313731
Potential fix for code scanning alert no. 35: Log entries created fro…
reakaleek Dec 1, 2025
ce6d0ea
Potential fix for code scanning alert no. 39: Log entries created fro…
reakaleek Dec 1, 2025
7dca09e
Potential fix for code scanning alert no. 43: Log entries created fro…
reakaleek Dec 1, 2025
65ad494
Also remove newlines
reakaleek Dec 1, 2025
f622191
Remove user input from logs
reakaleek Dec 1, 2025
717d3d2
Remove this troublesome log for now
reakaleek Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { ApiError } from '../errorHandling'
import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent'
import { GeneratingStatus } from './GeneratingStatus'
import { References } from './RelatedResources'
import { ChatMessage as ChatMessageType } from './chat.store'
import { ChatMessage as ChatMessageType, useConversationId } from './chat.store'
import { useMessageFeedback } from './useMessageFeedback'
import { useStatusMinDisplay } from './useStatusMinDisplay'
import {
EuiButtonIcon,
Expand Down Expand Up @@ -186,62 +187,80 @@ const computeAiStatus = (
// Action bar for complete AI messages
const ActionBar = ({
content,
messageId,
onRetry,
}: {
content: string
messageId: string
onRetry?: () => void
}) => (
<EuiFlexGroup responsive={false} component="span" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was helpful">
<EuiButtonIcon
aria-label="This answer was helpful"
iconType="thumbUp"
color="success"
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was not helpful">
<EuiButtonIcon
aria-label="This answer was not helpful"
iconType="thumbDown"
color="danger"
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={content}
beforeMessage="Copy markdown"
afterMessage="Copied!"
>
{(copy) => (
}) => {
const conversationId = useConversationId()
const { selectedReaction, submitFeedback } = useMessageFeedback(
messageId,
conversationId
)

return (
<EuiFlexGroup responsive={false} component="span" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was helpful">
<EuiButtonIcon
aria-label="Copy markdown"
iconType="copy"
aria-label="This answer was helpful"
iconType="thumbUp"
color="success"
size="s"
onClick={copy}
display={
selectedReaction === 'thumbsUp' ? 'base' : 'empty'
}
onClick={() => submitFeedback('thumbsUp')}
/>
)}
</EuiCopy>
</EuiFlexItem>
{onRetry && (
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content="Request a new answer">
<EuiToolTip content="This answer was not helpful">
<EuiButtonIcon
aria-label="Request a new answer"
iconType="refresh"
onClick={onRetry}
aria-label="This answer was not helpful"
iconType="thumbDown"
color="danger"
size="s"
display={
selectedReaction === 'thumbsDown' ? 'base' : 'empty'
}
onClick={() => submitFeedback('thumbsDown')}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
)
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={content}
beforeMessage="Copy markdown"
afterMessage="Copied!"
>
{(copy) => (
<EuiButtonIcon
aria-label="Copy markdown"
iconType="copy"
size="s"
onClick={copy}
/>
)}
</EuiCopy>
</EuiFlexItem>
{onRetry && (
<EuiFlexItem grow={false}>
<EuiToolTip content="Request a new answer">
<EuiButtonIcon
aria-label="Request a new answer"
iconType="refresh"
onClick={onRetry}
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
)
}

export const ChatMessage = ({
message,
Expand Down Expand Up @@ -412,6 +431,7 @@ export const ChatMessage = ({
<EuiSpacer size="m" />
<ActionBar
content={mainContent}
messageId={message.id}
onRetry={onRetry}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand/react'

export type AiProvider = 'AgentBuilder' | 'LlmGateway'
export type Reaction = 'thumbsUp' | 'thumbsDown'

export interface ChatMessage {
id: string
Expand All @@ -22,6 +23,7 @@ interface ChatState {
chatMessages: ChatMessage[]
conversationId: string | null
aiProvider: AiProvider
messageFeedback: Record<string, Reaction> // messageId -> reaction
actions: {
submitQuestion: (question: string) => void
updateAiMessage: (
Expand All @@ -37,13 +39,15 @@ interface ChatState {
hasMessageBeenSent: (id: string) => boolean
markMessageAsSent: (id: string) => void
cancelStreaming: () => void
setMessageFeedback: (messageId: string, reaction: Reaction) => void
}
}

export const chatStore = create<ChatState>((set) => ({
chatMessages: [],
conversationId: null, // Start with null - will be set by backend on first request
aiProvider: 'LlmGateway', // Default to LLM Gateway
messageFeedback: {},
actions: {
submitQuestion: (question: string) => {
set((state) => {
Expand Down Expand Up @@ -98,7 +102,7 @@ export const chatStore = create<ChatState>((set) => ({

clearChat: () => {
sentAiMessageIds.clear()
set({ chatMessages: [], conversationId: null })
set({ chatMessages: [], conversationId: null, messageFeedback: {} })
},

clearNon429Errors: () => {
Expand Down Expand Up @@ -136,6 +140,15 @@ export const chatStore = create<ChatState>((set) => ({
),
}))
},

setMessageFeedback: (messageId: string, reaction: Reaction) => {
set((state) => ({
messageFeedback: {
...state.messageFeedback,
[messageId]: reaction,
},
}))
},
},
}))

Expand All @@ -144,3 +157,5 @@ export const useConversationId = () =>
chatStore((state) => state.conversationId)
export const useAiProvider = () => chatStore((state) => state.aiProvider)
export const useChatActions = () => chatStore((state) => state.actions)
export const useMessageReaction = (messageId: string) =>
chatStore((state) => state.messageFeedback[messageId] ?? null)
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { logWarn } from '../../../telemetry/logging'
import { traceSpan } from '../../../telemetry/tracing'
import { Reaction, useChatActions, useMessageReaction } from './chat.store'
import { useMutation } from '@tanstack/react-query'
import { useCallback, useRef } from 'react'

export type { Reaction } from './chat.store'

const DEBOUNCE_MS = 500

interface MessageFeedbackRequest {
messageId: string
conversationId: string
reaction: Reaction
}

interface UseMessageFeedbackReturn {
selectedReaction: Reaction | null
submitFeedback: (reaction: Reaction) => void
}

const submitFeedbackToApi = async (
payload: MessageFeedbackRequest
): Promise<void> => {
await traceSpan('submit message-feedback', async (span) => {
span.setAttribute('gen_ai.conversation.id', payload.conversationId) // correlation with backend
span.setAttribute('ask_ai.message.id', payload.messageId)
span.setAttribute('ask_ai.feedback.reaction', payload.reaction)

const response = await fetch('/docs/_api/v1/ask-ai/message-feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})

if (!response.ok) {
logWarn('Failed to submit feedback', {
'http.status_code': response.status,
'ask_ai.message.id': payload.messageId,
'ask_ai.feedback.reaction': payload.reaction,
})
}
})
}

export const useMessageFeedback = (
messageId: string,
conversationId: string | null
): UseMessageFeedbackReturn => {
const selectedReaction = useMessageReaction(messageId)
const { setMessageFeedback } = useChatActions()
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)

const mutation = useMutation({

Check failure on line 58 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › Markdown rendering › should render markdown content

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:58:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:68) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:285:19)

Check failure on line 58 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should display Elastic logo icon

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:58:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:68) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:173:19)

Check failure on line 58 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should show feedback buttons

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:58:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:68) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:156:19)

Check failure on line 58 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should render AI message with correct content

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:58:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:68) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:144:19)
mutationFn: submitFeedbackToApi,
onError: (error) => {
logWarn('Error submitting feedback', {
'error.message':
error instanceof Error ? error.message : String(error),
})
// Don't reset selection on error - user intent was clear
},
})

const submitFeedback = useCallback(
(reaction: Reaction) => {
if (!conversationId) {
logWarn('Cannot submit feedback without conversationId', {
'ask_ai.message.id': messageId,
})
return
}

// Ignore if same reaction already selected
if (selectedReaction === reaction) {
return
}

// Optimistic update - stored in Zustand so it persists across tab switches
setMessageFeedback(messageId, reaction)

// Cancel any pending debounced submission
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}

// Debounce the API call - only send the final selection
debounceTimeoutRef.current = setTimeout(() => {
mutation.mutate({
messageId,
conversationId,
reaction,
})
}, DEBOUNCE_MS)
},
[
messageId,
conversationId,
selectedReaction,
mutation,
setMessageFeedback,
]
)

return {
selectedReaction,
submitFeedback,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.Json.Serialization;

namespace Elastic.Documentation.Api.Core.AskAi;

/// <summary>
/// Request model for submitting feedback on a specific Ask AI message.
/// Using Guid type ensures automatic validation during JSON deserialization.
/// </summary>
public record AskAiMessageFeedbackRequest(
Guid MessageId,
Guid ConversationId,
Reaction Reaction
);

/// <summary>
/// The user's reaction to an Ask AI message.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<Reaction>))]
public enum Reaction
{
[JsonStringEnumMemberName("thumbsUp")]
ThumbsUp,

[JsonStringEnumMemberName("thumbsDown")]
ThumbsDown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace Elastic.Documentation.Api.Core.AskAi;

/// <summary>
/// Use case for handling Ask AI message feedback submissions.
/// </summary>
public class AskAiMessageFeedbackUsecase(
IAskAiMessageFeedbackGateway feedbackGateway,
ILogger<AskAiMessageFeedbackUsecase> logger)
{
private static readonly ActivitySource FeedbackActivitySource = new(TelemetryConstants.AskAiFeedbackSourceName);

public async Task SubmitFeedback(AskAiMessageFeedbackRequest request, string? euid, CancellationToken ctx)
{
using var activity = FeedbackActivitySource.StartActivity("record message-feedback", ActivityKind.Internal);
_ = activity?.SetTag("gen_ai.conversation.id", request.ConversationId); // correlation with chat traces
_ = activity?.SetTag("ask_ai.message.id", request.MessageId);
_ = activity?.SetTag("ask_ai.feedback.reaction", request.Reaction.ToString().ToLowerInvariant());
// Note: user.euid is automatically added to spans by EuidSpanProcessor

// MessageId and ConversationId are Guid types, so no sanitization needed
logger.LogInformation(
"Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}",
request.MessageId,
request.ConversationId,
request.Reaction);

var record = new AskAiMessageFeedbackRecord(
request.MessageId,
request.ConversationId,
request.Reaction,
euid
);

await feedbackGateway.RecordFeedbackAsync(record, ctx);
}
}
Loading
Loading