Skip to content

Commit 1e52f45

Browse files
committed
Add UI onclick handler and other adjustments
1 parent 4ce3f48 commit 1e52f45

File tree

8 files changed

+208
-53
lines changed

8 files changed

+208
-53
lines changed

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { ApiError } from '../errorHandling'
55
import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent'
66
import { GeneratingStatus } from './GeneratingStatus'
77
import { References } from './RelatedResources'
8-
import { ChatMessage as ChatMessageType } from './chat.store'
8+
import { ChatMessage as ChatMessageType, useConversationId } from './chat.store'
9+
import { useMessageFeedback, Reaction } from './useMessageFeedback'
910
import { useStatusMinDisplay } from './useStatusMinDisplay'
1011
import {
1112
EuiButtonIcon,
@@ -186,62 +187,86 @@ const computeAiStatus = (
186187
// Action bar for complete AI messages
187188
const ActionBar = ({
188189
content,
190+
messageId,
189191
onRetry,
190192
}: {
191193
content: string
194+
messageId: string
192195
onRetry?: () => void
193-
}) => (
194-
<EuiFlexGroup responsive={false} component="span" gutterSize="none">
195-
<EuiFlexItem grow={false}>
196-
<EuiToolTip content="This answer was helpful">
197-
<EuiButtonIcon
198-
aria-label="This answer was helpful"
199-
iconType="thumbUp"
200-
color="success"
201-
size="s"
202-
/>
203-
</EuiToolTip>
204-
</EuiFlexItem>
205-
<EuiFlexItem grow={false}>
206-
<EuiToolTip content="This answer was not helpful">
207-
<EuiButtonIcon
208-
aria-label="This answer was not helpful"
209-
iconType="thumbDown"
210-
color="danger"
211-
size="s"
212-
/>
213-
</EuiToolTip>
214-
</EuiFlexItem>
215-
<EuiFlexItem grow={false}>
216-
<EuiCopy
217-
textToCopy={content}
218-
beforeMessage="Copy markdown"
219-
afterMessage="Copied!"
220-
>
221-
{(copy) => (
196+
}) => {
197+
const conversationId = useConversationId()
198+
const { selectedReaction, submitFeedback, isPending } = useMessageFeedback(
199+
messageId,
200+
conversationId
201+
)
202+
203+
const handleFeedback = (reaction: Reaction) => {
204+
if (!isPending) {
205+
submitFeedback(reaction)
206+
}
207+
}
208+
209+
return (
210+
<EuiFlexGroup responsive={false} component="span" gutterSize="xs">
211+
<EuiFlexItem grow={false}>
212+
<EuiToolTip content="This answer was helpful">
222213
<EuiButtonIcon
223-
aria-label="Copy markdown"
224-
iconType="copy"
214+
aria-label="This answer was helpful"
215+
iconType="thumbUp"
216+
color="success"
225217
size="s"
226-
onClick={copy}
218+
display={
219+
selectedReaction === 'thumbsUp' ? 'base' : 'empty'
220+
}
221+
onClick={() => handleFeedback('thumbsUp')}
227222
/>
228-
)}
229-
</EuiCopy>
230-
</EuiFlexItem>
231-
{onRetry && (
223+
</EuiToolTip>
224+
</EuiFlexItem>
232225
<EuiFlexItem grow={false}>
233-
<EuiToolTip content="Request a new answer">
226+
<EuiToolTip content="This answer was not helpful">
234227
<EuiButtonIcon
235-
aria-label="Request a new answer"
236-
iconType="refresh"
237-
onClick={onRetry}
228+
aria-label="This answer was not helpful"
229+
iconType="thumbDown"
230+
color="danger"
238231
size="s"
232+
display={
233+
selectedReaction === 'thumbsDown' ? 'base' : 'empty'
234+
}
235+
onClick={() => handleFeedback('thumbsDown')}
239236
/>
240237
</EuiToolTip>
241238
</EuiFlexItem>
242-
)}
243-
</EuiFlexGroup>
244-
)
239+
<EuiFlexItem grow={false}>
240+
<EuiCopy
241+
textToCopy={content}
242+
beforeMessage="Copy markdown"
243+
afterMessage="Copied!"
244+
>
245+
{(copy) => (
246+
<EuiButtonIcon
247+
aria-label="Copy markdown"
248+
iconType="copy"
249+
size="s"
250+
onClick={copy}
251+
/>
252+
)}
253+
</EuiCopy>
254+
</EuiFlexItem>
255+
{onRetry && (
256+
<EuiFlexItem grow={false}>
257+
<EuiToolTip content="Request a new answer">
258+
<EuiButtonIcon
259+
aria-label="Request a new answer"
260+
iconType="refresh"
261+
onClick={onRetry}
262+
size="s"
263+
/>
264+
</EuiToolTip>
265+
</EuiFlexItem>
266+
)}
267+
</EuiFlexGroup>
268+
)
269+
}
245270

246271
export const ChatMessage = ({
247272
message,
@@ -412,6 +437,7 @@ export const ChatMessage = ({
412437
<EuiSpacer size="m" />
413438
<ActionBar
414439
content={mainContent}
440+
messageId={message.id}
415441
onRetry={onRetry}
416442
/>
417443
</>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { logWarn } from '../../../telemetry/logging'
2+
import { traceSpan } from '../../../telemetry/tracing'
3+
import { useMutation } from '@tanstack/react-query'
4+
import { useState, useCallback } from 'react'
5+
6+
export type Reaction = 'thumbsUp' | 'thumbsDown'
7+
8+
interface MessageFeedbackRequest {
9+
messageId: string
10+
conversationId: string
11+
reaction: Reaction
12+
}
13+
14+
interface UseMessageFeedbackReturn {
15+
selectedReaction: Reaction | null
16+
submitFeedback: (reaction: Reaction) => void
17+
isPending: boolean
18+
}
19+
20+
const submitFeedbackToApi = async (
21+
payload: MessageFeedbackRequest
22+
): Promise<void> => {
23+
await traceSpan('submit message-feedback', async (span) => {
24+
span.setAttribute('gen_ai.conversation.id', payload.conversationId) // correlation with backend
25+
span.setAttribute('ask_ai.message.id', payload.messageId)
26+
span.setAttribute('ask_ai.feedback.reaction', payload.reaction)
27+
28+
const response = await fetch('/docs/_api/v1/ask-ai/message-feedback', {
29+
method: 'POST',
30+
headers: {
31+
'Content-Type': 'application/json',
32+
},
33+
body: JSON.stringify(payload),
34+
})
35+
36+
if (!response.ok) {
37+
logWarn('Failed to submit feedback', {
38+
'http.status_code': response.status,
39+
'ask_ai.message.id': payload.messageId,
40+
'ask_ai.feedback.reaction': payload.reaction,
41+
})
42+
}
43+
})
44+
}
45+
46+
export const useMessageFeedback = (
47+
messageId: string,
48+
conversationId: string | null
49+
): UseMessageFeedbackReturn => {
50+
const [selectedReaction, setSelectedReaction] = useState<Reaction | null>(
51+
null
52+
)
53+
54+
const mutation = useMutation({
55+
mutationFn: submitFeedbackToApi,
56+
onError: (error) => {
57+
logWarn('Error submitting feedback', {
58+
'error.message':
59+
error instanceof Error ? error.message : String(error),
60+
})
61+
// Don't reset selection on error - user intent was clear
62+
},
63+
})
64+
65+
const submitFeedback = useCallback(
66+
(reaction: Reaction) => {
67+
if (!conversationId) {
68+
console.warn('Cannot submit feedback without conversationId')
69+
return
70+
}
71+
72+
// Ignore if same reaction already selected
73+
if (selectedReaction === reaction) {
74+
return
75+
}
76+
77+
// Ignore if already submitting
78+
if (mutation.isPending) {
79+
return
80+
}
81+
82+
// Optimistic update
83+
setSelectedReaction(reaction)
84+
85+
// Submit to API
86+
mutation.mutate({
87+
messageId,
88+
conversationId,
89+
reaction,
90+
})
91+
},
92+
[messageId, conversationId, selectedReaction, mutation]
93+
)
94+
95+
return {
96+
selectedReaction,
97+
submitFeedback,
98+
isPending: mutation.isPending,
99+
}
100+
}

src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackRequest.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.Text.Json.Serialization;
6+
57
namespace Elastic.Documentation.Api.Core.AskAi;
68

79
/// <summary>
@@ -16,8 +18,12 @@ Reaction Reaction
1618
/// <summary>
1719
/// The user's reaction to an Ask AI message.
1820
/// </summary>
21+
[JsonConverter(typeof(JsonStringEnumConverter<Reaction>))]
1922
public enum Reaction
2023
{
24+
[JsonStringEnumMemberName("thumbsUp")]
2125
ThumbsUp,
26+
27+
[JsonStringEnumMemberName("thumbsDown")]
2228
ThumbsDown
2329
}

src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackUsecase.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ public class AskAiMessageFeedbackUsecase(
1616
{
1717
private static readonly ActivitySource FeedbackActivitySource = new(TelemetryConstants.AskAiFeedbackSourceName);
1818

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

2627
logger.LogInformation(
2728
"Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}",
@@ -32,7 +33,8 @@ public async Task SubmitFeedback(AskAiMessageFeedbackRequest request, Cancellati
3233
var record = new AskAiMessageFeedbackRecord(
3334
request.MessageId,
3435
request.ConversationId,
35-
request.Reaction
36+
request.Reaction,
37+
euid
3638
);
3739

3840
await feedbackGateway.RecordFeedbackAsync(record, ctx);

src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiMessageFeedbackGateway.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ public interface IAskAiMessageFeedbackGateway
2424
public record AskAiMessageFeedbackRecord(
2525
string MessageId,
2626
string ConversationId,
27-
Reaction Reaction
27+
Reaction Reaction,
28+
string? Euid = null
2829
);

src/api/Elastic.Documentation.Api.Core/SerializationContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public record OutputMessage(string Role, MessagePart[] Parts, string FinishReaso
1919

2020
[JsonSerializable(typeof(AskAiRequest))]
2121
[JsonSerializable(typeof(AskAiMessageFeedbackRequest))]
22+
[JsonSerializable(typeof(Reaction))]
2223
[JsonSerializable(typeof(SearchRequest))]
2324
[JsonSerializable(typeof(SearchResponse))]
2425
[JsonSerializable(typeof(InputMessage))]

src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,22 @@ public ElasticsearchAskAiMessageFeedbackGateway(
4242

4343
public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, CancellationToken ctx)
4444
{
45+
var feedbackId = Guid.NewGuid().ToString();
4546
var document = new MessageFeedbackDocument
4647
{
48+
FeedbackId = feedbackId,
4749
MessageId = record.MessageId,
4850
ConversationId = record.ConversationId,
4951
Reaction = record.Reaction.ToString().ToLowerInvariant(),
52+
Euid = record.Euid,
5053
Timestamp = DateTimeOffset.UtcNow
5154
};
5255

53-
var response = await _client.IndexAsync(document, _indexName, ctx);
56+
_logger.LogDebug("Indexing feedback with ID {FeedbackId} to index {IndexName}", feedbackId, _indexName);
57+
58+
var response = await _client.IndexAsync<MessageFeedbackDocument>(document, idx => idx
59+
.Index(_indexName)
60+
.Id(feedbackId), ctx);
5461

5562
if (!response.IsValidResponse)
5663
{
@@ -62,16 +69,21 @@ public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, Cancell
6269
else
6370
{
6471
_logger.LogInformation(
65-
"Message feedback recorded: {Reaction} for message {MessageId} in conversation {ConversationId}",
72+
"Message feedback recorded: {Reaction} for message {MessageId} in conversation {ConversationId}. ES _id: {EsId}, Index: {Index}",
6673
record.Reaction,
6774
record.MessageId,
68-
record.ConversationId);
75+
record.ConversationId,
76+
response.Id,
77+
response.Index);
6978
}
7079
}
7180
}
7281

7382
internal sealed record MessageFeedbackDocument
7483
{
84+
[JsonPropertyName("feedback_id")]
85+
public required string FeedbackId { get; init; }
86+
7587
[JsonPropertyName("message_id")]
7688
public required string MessageId { get; init; }
7789

@@ -81,6 +93,10 @@ internal sealed record MessageFeedbackDocument
8193
[JsonPropertyName("reaction")]
8294
public required string Reaction { get; init; }
8395

96+
[JsonPropertyName("euid")]
97+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
98+
public string? Euid { get; init; }
99+
84100
[JsonPropertyName("@timestamp")]
85101
public required DateTimeOffset Timestamp { get; init; }
86102
}

src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ private static void MapAskAiEndpoint(IEndpointRouteBuilder group)
3636
await stream.CopyToAsync(context.Response.Body, ctx);
3737
});
3838

39-
_ = askAiGroup.MapPost("/message-feedback", async (AskAiMessageFeedbackRequest request, AskAiMessageFeedbackUsecase feedbackUsecase, Cancel ctx) =>
39+
_ = askAiGroup.MapPost("/message-feedback", async (HttpContext context, AskAiMessageFeedbackRequest request, AskAiMessageFeedbackUsecase feedbackUsecase, Cancel ctx) =>
4040
{
41-
await feedbackUsecase.SubmitFeedback(request, ctx);
41+
// Extract euid cookie for user tracking
42+
_ = context.Request.Cookies.TryGetValue("euid", out var euid);
43+
44+
await feedbackUsecase.SubmitFeedback(request, euid, ctx);
4245
return Results.NoContent();
43-
});
46+
}).DisableAntiforgery();
4447
}
4548

4649
private static void MapSearchEndpoint(IEndpointRouteBuilder group)

0 commit comments

Comments
 (0)