Skip to content

Commit 592e7d9

Browse files
reakaleekCopilot
andauthored
Dynamic AI response status and related resources display (#2030)
* Dynamic AI response status and related resources display * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Run prettier * Refactor * Use html comment delimiter To increase the chance that the json will not be rendered on the UI * Simplify prompt * Update prompt --------- Co-authored-by: Copilot <[email protected]>
1 parent b3b15c4 commit 592e7d9

File tree

7 files changed

+374
-86
lines changed

7 files changed

+374
-86
lines changed

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@elastic/eui'
1616
import { css } from '@emotion/react'
1717
import * as React from 'react'
18-
import { useCallback, useEffect, useRef } from 'react'
18+
import { useCallback, useEffect, useRef, useState } from 'react'
1919

2020
const containerStyles = css`
2121
height: 100%;
@@ -66,6 +66,7 @@ export const Chat = () => {
6666
const inputRef = useRef<HTMLInputElement>(null)
6767
const scrollRef = useRef<HTMLDivElement>(null)
6868
const lastMessageStatusRef = useRef<string | null>(null)
69+
const [inputValue, setInputValue] = useState('')
6970

7071
const dynamicScrollableStyles = css`
7172
${scrollableStyles}
@@ -81,6 +82,7 @@ export const Chat = () => {
8182
if (inputRef.current) {
8283
inputRef.current.value = ''
8384
}
85+
setInputValue('')
8486

8587
// Scroll to bottom after new message
8688
setTimeout(() => scrollToBottom(scrollRef.current), 100)
@@ -202,6 +204,7 @@ export const Chat = () => {
202204
inputRef={inputRef}
203205
fullWidth
204206
placeholder="Ask Elastic Docs AI Assistant"
207+
onChange={(e) => setInputValue(e.target.value)}
205208
onKeyDown={(e) => {
206209
if (e.key === 'Enter') {
207210
handleSubmit(e.currentTarget.value)
@@ -219,7 +222,7 @@ export const Chat = () => {
219222
`}
220223
color="primary"
221224
iconType="sortUp"
222-
display="base"
225+
display={inputValue.trim() ? 'fill' : 'base'}
223226
onClick={() => {
224227
if (inputRef.current) {
225228
handleSubmit(inputRef.current.value)

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

Lines changed: 133 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { initCopyButton } from '../../../copybutton'
2+
import { GeneratingStatus } from './GeneratingStatus'
3+
import { References } from './RelatedResources'
24
import { ChatMessage as ChatMessageType } from './chat.store'
35
import { LlmGatewayMessage } from './useLlmGateway'
46
import {
5-
EuiAvatar,
67
EuiButtonIcon,
78
EuiCallOut,
89
EuiCopy,
910
EuiFlexGroup,
1011
EuiFlexItem,
1112
EuiIcon,
1213
EuiLoadingElastic,
13-
EuiLoadingSpinner,
1414
EuiPanel,
1515
EuiSpacer,
1616
EuiText,
@@ -58,13 +58,90 @@ const getAccumulatedContent = (messages: LlmGatewayMessage[]) => {
5858
.join('')
5959
}
6060

61+
const splitContentAndReferences = (
62+
content: string
63+
): { mainContent: string; referencesJson: string | null } => {
64+
const startDelimiter = '<!--REFERENCES'
65+
const endDelimiter = '-->'
66+
67+
const startIndex = content.indexOf(startDelimiter)
68+
if (startIndex === -1) {
69+
return { mainContent: content, referencesJson: null }
70+
}
71+
72+
const endIndex = content.indexOf(endDelimiter, startIndex)
73+
if (endIndex === -1) {
74+
return { mainContent: content, referencesJson: null }
75+
}
76+
77+
const mainContent = content.substring(0, startIndex).trim()
78+
const referencesJson = content
79+
.substring(startIndex + startDelimiter.length, endIndex)
80+
.trim()
81+
82+
return { mainContent, referencesJson }
83+
}
84+
6185
const getMessageState = (message: ChatMessageType) => ({
6286
isUser: message.type === 'user',
6387
isLoading: message.status === 'streaming',
6488
isComplete: message.status === 'complete',
6589
hasError: message.status === 'error',
6690
})
6791

92+
// Helper functions for computing AI status
93+
const getToolCallSearchQuery = (
94+
messages: LlmGatewayMessage[]
95+
): string | null => {
96+
const toolCallMessage = messages.find((m) => m.type === 'tool_call')
97+
if (!toolCallMessage) return null
98+
99+
try {
100+
const toolCalls = toolCallMessage.data?.toolCalls
101+
if (toolCalls && toolCalls.length > 0) {
102+
const firstToolCall = toolCalls[0]
103+
return firstToolCall.args?.searchQuery || null
104+
}
105+
} catch (e) {
106+
console.error('Error extracting search query from tool call:', e)
107+
}
108+
109+
return null
110+
}
111+
112+
const hasContentStarted = (messages: LlmGatewayMessage[]): boolean => {
113+
return messages.some((m) => m.type === 'ai_message_chunk' && m.data.content)
114+
}
115+
116+
const hasReachedReferences = (messages: LlmGatewayMessage[]): boolean => {
117+
const accumulatedContent = messages
118+
.filter((m) => m.type === 'ai_message_chunk')
119+
.map((m) => m.data.content)
120+
.join('')
121+
return accumulatedContent.includes('<!--REFERENCES')
122+
}
123+
124+
const computeAiStatus = (
125+
llmMessages: LlmGatewayMessage[],
126+
isComplete: boolean
127+
): string | null => {
128+
if (isComplete) return null
129+
130+
const searchQuery = getToolCallSearchQuery(llmMessages)
131+
const contentStarted = hasContentStarted(llmMessages)
132+
const reachedReferences = hasReachedReferences(llmMessages)
133+
134+
if (reachedReferences) {
135+
return 'Gathering resources'
136+
} else if (contentStarted) {
137+
return 'Generating'
138+
} else if (searchQuery) {
139+
return `Searching for "${searchQuery}"`
140+
}
141+
142+
return 'Thinking'
143+
}
144+
68145
// Action bar for complete AI messages
69146
const ActionBar = ({
70147
content,
@@ -73,7 +150,7 @@ const ActionBar = ({
73150
content: string
74151
onRetry?: () => void
75152
}) => (
76-
<EuiFlexGroup responsive={false} component="span" gutterSize="s">
153+
<EuiFlexGroup responsive={false} component="span" gutterSize="none">
77154
<EuiFlexItem grow={false}>
78155
<EuiToolTip content="This answer was helpful">
79156
<EuiButtonIcon
@@ -137,34 +214,27 @@ export const ChatMessage = ({
137214

138215
if (isUser) {
139216
return (
140-
<EuiFlexGroup
141-
gutterSize="s"
142-
alignItems="flexStart"
143-
responsive={false}
217+
<div
144218
data-message-type="user"
145219
data-message-id={message.id}
220+
css={css`
221+
max-width: 50%;
222+
justify-self: flex-end;
223+
`}
146224
>
147-
<EuiFlexItem grow={false}>
148-
<EuiAvatar
149-
name="User"
150-
size="m"
151-
color="#6DCCB1"
152-
iconType="user"
153-
/>
154-
</EuiFlexItem>
155-
<EuiFlexItem>
156-
<EuiPanel
157-
paddingSize="m"
158-
hasShadow={false}
159-
hasBorder={true}
160-
css={css`
161-
background-color: ${euiTheme.colors.emptyShade};
162-
`}
163-
>
164-
<EuiText size="s">{message.content}</EuiText>
165-
</EuiPanel>
166-
</EuiFlexItem>
167-
</EuiFlexGroup>
225+
<EuiPanel
226+
paddingSize="s"
227+
hasShadow={false}
228+
hasBorder={true}
229+
css={css`
230+
border-radius: ${euiTheme.border.radius.medium};
231+
background-color: ${euiTheme.colors
232+
.backgroundLightText};
233+
`}
234+
>
235+
<EuiText size="s">{message.content}</EuiText>
236+
</EuiPanel>
237+
</div>
168238
)
169239
}
170240

@@ -176,10 +246,32 @@ export const ChatMessage = ({
176246

177247
const hasError = message.status === 'error' || !!error
178248

249+
// Only split content and references when complete for better performance
250+
const { mainContent, referencesJson } = useMemo(() => {
251+
if (isComplete) {
252+
return splitContentAndReferences(content)
253+
}
254+
// During streaming, strip out unparsed references but don't parse them yet
255+
const startDelimiter = '<!--REFERENCES'
256+
const delimiterIndex = content.indexOf(startDelimiter)
257+
if (delimiterIndex !== -1) {
258+
return {
259+
mainContent: content.substring(0, delimiterIndex).trim(),
260+
referencesJson: null,
261+
}
262+
}
263+
return { mainContent: content, referencesJson: null }
264+
}, [content, isComplete])
265+
179266
const parsed = useMemo(() => {
180-
const html = markedInstance.parse(content) as string
267+
const html = markedInstance.parse(mainContent) as string
181268
return DOMPurify.sanitize(html)
182-
}, [content])
269+
}, [mainContent])
270+
271+
const aiStatus = useMemo(
272+
() => computeAiStatus(llmMessages, isComplete),
273+
[llmMessages, isComplete]
274+
)
183275

184276
const ref = React.useRef<HTMLDivElement>(null)
185277

@@ -239,16 +331,12 @@ export const ChatMessage = ({
239331
<EuiPanel
240332
paddingSize="m"
241333
hasShadow={false}
242-
hasBorder={true}
334+
hasBorder={false}
243335
css={css`
244-
background-color: ${euiTheme.colors
245-
.backgroundLightText};
336+
padding-top: 8px;
246337
`}
247338
>
248339
{content && (
249-
// <EuiMarkdownFormat css={markdownFormatStyles}>
250-
// {content}
251-
// </EuiMarkdownFormat>
252340
<div
253341
ref={ref}
254342
className="markdown-content"
@@ -262,30 +350,20 @@ export const ChatMessage = ({
262350
/>
263351
)}
264352

265-
{isLoading && (
266-
<>
267-
{content && <EuiSpacer size="s" />}
268-
<EuiFlexGroup
269-
alignItems="center"
270-
gutterSize="s"
271-
responsive={false}
272-
>
273-
<EuiFlexItem grow={false}>
274-
<EuiLoadingSpinner size="s" />
275-
</EuiFlexItem>
276-
<EuiFlexItem grow={false}>
277-
<EuiText size="xs" color="subdued">
278-
Generating...
279-
</EuiText>
280-
</EuiFlexItem>
281-
</EuiFlexGroup>
282-
</>
353+
{referencesJson && (
354+
<References referencesJson={referencesJson} />
283355
)}
284356

357+
{content && isLoading && <EuiSpacer size="m" />}
358+
<GeneratingStatus status={aiStatus} />
359+
285360
{isComplete && content && (
286361
<>
287362
<EuiSpacer size="m" />
288-
<ActionBar content={content} onRetry={onRetry} />
363+
<ActionBar
364+
content={mainContent}
365+
onRetry={onRetry}
366+
/>
289367
</>
290368
)}
291369

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const ChatMessageList = ({ messages }: ChatMessageListProps) => {
2121
isLast={index === messages.length - 1}
2222
/>
2323
)}
24-
{index < messages.length - 1 && <EuiSpacer size="m" />}
24+
{index < messages.length - 1 && <EuiSpacer size="l" />}
2525
</React.Fragment>
2626
))}
2727
</>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** @jsxImportSource @emotion/react */
2+
import {
3+
EuiFlexGroup,
4+
EuiFlexItem,
5+
EuiLoadingSpinner,
6+
EuiText,
7+
} from '@elastic/eui'
8+
import * as React from 'react'
9+
10+
interface GeneratingStatusProps {
11+
status: string | null
12+
}
13+
14+
export const GeneratingStatus = ({ status }: GeneratingStatusProps) => {
15+
if (!status) {
16+
return null
17+
}
18+
19+
return (
20+
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
21+
<EuiFlexItem grow={false}>
22+
<EuiLoadingSpinner size="s" />
23+
</EuiFlexItem>
24+
<EuiFlexItem grow={false}>
25+
<EuiText size="xs" color="subdued">
26+
{status}...
27+
</EuiText>
28+
</EuiFlexItem>
29+
</EuiFlexGroup>
30+
)
31+
}

0 commit comments

Comments
 (0)