-
Notifications
You must be signed in to change notification settings - Fork 8.6k
Add streaming to AI Insights #247644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add streaming to AI Insights #247644
Changes from 33 commits
5b709bc
f5b7701
adfa0d8
2b61974
be99599
d9dda81
d3826a5
d86255f
0fba3a9
a3ae8ff
762e2c7
43a3347
a65342d
d073803
b9100db
14613f2
42d1a76
fd3fa36
06f3188
eb0cdf1
3693afb
5164de5
33d14d3
92ee2ef
9dfda7a
1e8bdcf
d1247cf
13a658a
48e647c
fad07af
56559bd
ba6ed9f
314e190
08625df
8f62336
8e2f16a
5b2a8eb
e14f2d6
005bf85
ea2958c
b579083
43e59c9
13faa71
563b593
acd099c
ff45961
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the Elastic License | ||
| * 2.0; you may not use this file except in compliance with the Elastic License | ||
| * 2.0. | ||
| */ | ||
|
|
||
| import React from 'react'; | ||
| import { render, fireEvent } from '@testing-library/react'; | ||
| import { EuiThemeProvider } from '@elastic/eui'; | ||
| import { AIChatExperience } from '@kbn/ai-assistant-common'; | ||
| import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; | ||
| import { AiInsight } from './ai_insight'; | ||
| import { useKibana } from '../../hooks/use_kibana'; | ||
| import { useLicense } from '../../hooks/use_license'; | ||
| import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; | ||
| import { useStreamingAiInsight } from '../../hooks/use_streaming_ai_insight'; | ||
|
|
||
| jest.mock('@kbn/kibana-react-plugin/public', () => ({ | ||
| useUiSetting$: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('../../hooks/use_kibana'); | ||
| jest.mock('../../hooks/use_license'); | ||
| jest.mock('../../hooks/use_genai_connectors'); | ||
| jest.mock('../../hooks/use_streaming_ai_insight'); | ||
|
|
||
| const mockUseUiSetting$ = useUiSetting$ as jest.Mock; | ||
| const mockUseKibana = useKibana as jest.Mock; | ||
| const mockUseLicense = useLicense as jest.Mock; | ||
| const mockUseGenAIConnectors = useGenAIConnectors as jest.Mock; | ||
| const mockUseStreamingAiInsight = useStreamingAiInsight as jest.Mock; | ||
| const mockCreateStream = jest.fn(); | ||
| const AiInsightTest = AiInsight as React.ComponentType<any>; | ||
|
|
||
| const renderComponent = () => | ||
| render( | ||
| <EuiThemeProvider> | ||
| <AiInsightTest | ||
| title="AI Insight" | ||
| createStream={mockCreateStream} | ||
| buildAttachments={jest.fn().mockReturnValue([])} | ||
| /> | ||
| </EuiThemeProvider> | ||
| ); | ||
|
|
||
| const mockOpenConversationFlyout = jest.fn(); | ||
|
|
||
| const baseStreamingState = () => ({ | ||
| isLoading: false, | ||
| error: undefined as string | undefined, | ||
| summary: '', | ||
| context: '', | ||
| wasStopped: false, | ||
| fetch: jest.fn(), | ||
| stop: jest.fn(), | ||
| regenerate: jest.fn(), | ||
| }); | ||
|
|
||
| const createStreamingState = (overrides: Partial<ReturnType<typeof baseStreamingState>> = {}) => ({ | ||
| ...baseStreamingState(), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| describe('AiInsight', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
|
|
||
| mockUseUiSetting$.mockReturnValue([AIChatExperience.Agent]); | ||
| mockUseKibana.mockReturnValue({ | ||
| services: { | ||
| agentBuilder: { | ||
| openConversationFlyout: mockOpenConversationFlyout, | ||
| }, | ||
| application: { | ||
| capabilities: { | ||
| agentBuilder: { show: true }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| mockUseLicense.mockReturnValue({ | ||
| getLicense: () => ({ | ||
| hasAtLeast: () => true, | ||
| }), | ||
| }); | ||
| mockUseGenAIConnectors.mockReturnValue({ | ||
| hasConnectors: true, | ||
| }); | ||
| mockUseStreamingAiInsight.mockReturnValue(createStreamingState()); | ||
| }); | ||
|
|
||
| it('fetches insights when the accordion is opened', () => { | ||
| const fetch = jest.fn(); | ||
| mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ fetch })); | ||
|
|
||
| const { container, unmount } = renderComponent(); | ||
| const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); | ||
|
|
||
| expect(toggle).toBeTruthy(); | ||
| fireEvent.click(toggle!); | ||
|
|
||
| expect(fetch).toHaveBeenCalledTimes(1); | ||
| unmount(); | ||
| }); | ||
|
|
||
| it('shows error banner and retries on click', () => { | ||
| const fetch = jest.fn(); | ||
| mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: 'Boom', fetch })); | ||
|
|
||
| const { container, unmount } = renderComponent(); | ||
| const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); | ||
| fireEvent.click(toggle!); | ||
|
|
||
| const retryButton = container.querySelector( | ||
| '[data-test-subj="AiInsightErrorBannerRetryButton"]' | ||
| ); | ||
|
|
||
| expect(retryButton).toBeTruthy(); | ||
| fireEvent.click(retryButton!); | ||
|
|
||
| expect(fetch).toHaveBeenCalledTimes(1); | ||
| unmount(); | ||
| }); | ||
|
|
||
| it('renders regenerate and start conversation actions when summary exists', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is testing too many things in a single test. Consider splitting is as follows: Example: |
||
| const regenerate = jest.fn(); | ||
| const buildAttachments = jest.fn().mockReturnValue([{ type: 'test', data: {} }]); | ||
| mockUseStreamingAiInsight.mockReturnValue( | ||
| createStreamingState({ summary: 'Hello world', context: 'context', regenerate }) | ||
| ); | ||
|
|
||
| const { container, unmount } = render( | ||
| <EuiThemeProvider> | ||
| <AiInsightTest | ||
| title="AI Insight" | ||
| createStream={mockCreateStream} | ||
| buildAttachments={buildAttachments} | ||
| /> | ||
| </EuiThemeProvider> | ||
| ); | ||
|
|
||
| const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); | ||
| fireEvent.click(toggle!); | ||
|
|
||
| const regenerateButton = container.querySelector( | ||
| '[data-test-subj="observabilityAgentBuilderRegenerateButton"]' | ||
| ); | ||
| fireEvent.click(regenerateButton!); | ||
| expect(regenerate).toHaveBeenCalledTimes(1); | ||
|
|
||
| const startConversationButton = container.querySelector( | ||
| '[data-test-subj="aiAgentStartConversationButton"]' | ||
| ); | ||
| fireEvent.click(startConversationButton!); | ||
|
|
||
| expect(buildAttachments).toHaveBeenCalledWith('Hello world', 'context'); | ||
| expect(mockOpenConversationFlyout).toHaveBeenCalledWith({ | ||
| newConversation: true, | ||
| attachments: [{ type: 'test', data: {} }], | ||
| }); | ||
| unmount(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,24 +14,26 @@ import { | |
| EuiPanel, | ||
| EuiSpacer, | ||
| EuiText, | ||
| EuiSkeletonText, | ||
| EuiMarkdownFormat, | ||
| EuiButtonEmpty, | ||
| EuiHorizontalRule, | ||
| useEuiTheme, | ||
| } from '@elastic/eui'; | ||
| import { i18n } from '@kbn/i18n'; | ||
| import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; | ||
| import { AIChatExperience } from '@kbn/ai-assistant-common'; | ||
| import { AI_CHAT_EXPERIENCE_TYPE } from '@kbn/management-settings-ids'; | ||
| import { EuiMarkdownFormat } from '@elastic/eui'; | ||
| import type { Observable } from 'rxjs'; | ||
| import { useKibana } from '../../hooks/use_kibana'; | ||
| import { useLicense } from '../../hooks/use_license'; | ||
| import { | ||
| useStreamingAiInsight, | ||
| type InsightStreamEvent, | ||
| } from '../../hooks/use_streaming_ai_insight'; | ||
| import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; | ||
| import { StartConversationButton } from './start_conversation_button'; | ||
| import { AiInsightErrorBanner } from './ai_insight_error_banner'; | ||
|
|
||
| export interface AiInsightResponse { | ||
| summary: string; | ||
| context: string; | ||
| } | ||
| import { useMarkdownPluginsWithCursor, CURSOR } from './loading_cursor'; | ||
|
|
||
| export interface AiInsightAttachment { | ||
| type: string; | ||
|
|
@@ -41,17 +43,13 @@ export interface AiInsightAttachment { | |
|
|
||
| export interface AiInsightProps { | ||
| title: string; | ||
| fetchInsight: () => Promise<AiInsightResponse>; | ||
| createStream: (signal: AbortSignal) => Observable<InsightStreamEvent>; | ||
| buildAttachments: (summary: string, context: string) => AiInsightAttachment[]; | ||
| } | ||
|
|
||
| export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightProps) { | ||
| export function AiInsight({ title, createStream, buildAttachments }: AiInsightProps) { | ||
| const { euiTheme } = useEuiTheme(); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [error, setError] = useState<string | undefined>(undefined); | ||
| const [summary, setSummary] = useState(''); | ||
| const [context, setContext] = useState(''); | ||
|
|
||
| const { | ||
| services: { agentBuilder, application }, | ||
|
|
@@ -68,19 +66,12 @@ export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightPr | |
| const hasEnterpriseLicense = license?.hasAtLeast('enterprise'); | ||
| const hasAgentBuilderAccess = application?.capabilities.agentBuilder?.show === true; | ||
|
|
||
| const handleFetchInsight = useCallback(async () => { | ||
| setIsLoading(true); | ||
| setError(undefined); | ||
| try { | ||
| const response = await fetchInsight(); | ||
| setSummary(response.summary); | ||
| setContext(response.context); | ||
| } catch (e) { | ||
| setError(e instanceof Error ? e.message : 'Failed to load AI insight'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [fetchInsight]); | ||
| const { isLoading, error, summary, context, wasStopped, fetch, stop, regenerate } = | ||
| useStreamingAiInsight(createStream); | ||
|
|
||
| const { parsingPluginList, processingPluginList } = useMarkdownPluginsWithCursor( | ||
| euiTheme.colors.text | ||
| ); | ||
|
|
||
| const handleStartConversation = useCallback(() => { | ||
| if (!agentBuilder?.openConversationFlyout) return; | ||
|
|
@@ -142,31 +133,98 @@ export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightPr | |
| onToggle={(open) => { | ||
| setIsOpen(open); | ||
| if (open && !error && !summary && !isLoading) { | ||
| handleFetchInsight(); | ||
| fetch(); | ||
| } | ||
| }} | ||
| > | ||
| <EuiSpacer size="m" /> | ||
| <EuiPanel color="subdued"> | ||
| {isLoading ? ( | ||
| <EuiSkeletonText lines={3} /> | ||
| ) : error ? ( | ||
| <AiInsightErrorBanner error={error} onRetry={handleFetchInsight} /> | ||
| {error ? ( | ||
| <AiInsightErrorBanner error={error} onRetry={fetch} /> | ||
| ) : ( | ||
| <EuiMarkdownFormat textSize="s">{summary}</EuiMarkdownFormat> | ||
| <EuiText size="s"> | ||
| <EuiMarkdownFormat | ||
| textSize="s" | ||
| parsingPluginList={parsingPluginList} | ||
| processingPluginList={processingPluginList} | ||
| > | ||
| {`${summary}${isLoading ? CURSOR : ''}`} | ||
| </EuiMarkdownFormat> | ||
| </EuiText> | ||
| )} | ||
| </EuiPanel> | ||
|
|
||
| {!isLoading && Boolean(summary && summary.trim()) ? ( | ||
| <> | ||
| <EuiSpacer size="m" /> | ||
| <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}> | ||
| <EuiFlexItem grow={false}> | ||
| <StartConversationButton onClick={handleStartConversation} /> | ||
| </EuiFlexItem> | ||
| </EuiFlexGroup> | ||
| </> | ||
| ) : null} | ||
| {isLoading ? ( | ||
| <> | ||
| <EuiSpacer size="m" /> | ||
| <EuiHorizontalRule margin="none" /> | ||
| <EuiSpacer size="s" /> | ||
| <EuiFlexGroup justifyContent="flexStart" gutterSize="s" responsive={false}> | ||
| <EuiFlexItem grow={false}> | ||
| <EuiButtonEmpty | ||
| data-test-subj="observabilityAgentBuilderStopGeneratingButton" | ||
| color="text" | ||
| iconType="stop" | ||
| size="s" | ||
| onClick={stop} | ||
| > | ||
| {i18n.translate( | ||
| 'xpack.observabilityAgentBuilder.aiInsight.stopGeneratingButton', | ||
| { | ||
| defaultMessage: 'Stop generating', | ||
| } | ||
| )} | ||
| </EuiButtonEmpty> | ||
| </EuiFlexItem> | ||
| </EuiFlexGroup> | ||
| </> | ||
| ) : wasStopped ? ( | ||
| <> | ||
| <EuiSpacer size="m" /> | ||
| <EuiHorizontalRule margin="none" /> | ||
| <EuiSpacer size="s" /> | ||
| <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}> | ||
| <EuiFlexItem grow={false}> | ||
| <EuiButtonEmpty | ||
| data-test-subj="observabilityAgentBuilderRegenerateButton" | ||
| size="s" | ||
| iconType="sparkles" | ||
| onClick={regenerate} | ||
| > | ||
| {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', { | ||
| defaultMessage: 'Regenerate', | ||
| })} | ||
| </EuiButtonEmpty> | ||
| </EuiFlexItem> | ||
| <EuiFlexItem grow={false}> | ||
| <StartConversationButton onClick={handleStartConversation} /> | ||
| </EuiFlexItem> | ||
| </EuiFlexGroup> | ||
| </> | ||
| ) : Boolean(summary && summary.trim()) ? ( | ||
| <> | ||
| <EuiSpacer size="m" /> | ||
| <EuiHorizontalRule margin="none" /> | ||
| <EuiSpacer size="s" /> | ||
| <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}> | ||
| <EuiFlexItem grow={false}> | ||
|
Comment on lines
+154
to
+209
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the conditions here makes it very hard to read this component. Then in the JSX: WDYT? |
||
| <EuiButtonEmpty | ||
| data-test-subj="observabilityAgentBuilderRegenerateButton" | ||
| size="s" | ||
| iconType="sparkles" | ||
| onClick={regenerate} | ||
| > | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We decided not to show the Regenerate button if there is no error yeah? Did that decision change? (Maybe I missed something)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m fine with removing it. my understanding was that we wouldn’t want to add it as a separate ticket, because it would make sense to handle it within the prompt edit. But since we’re implementing the stop functionality, it wouldn’t hurt to keep it there anyway - we know that the LLM can give slightly different answers each time we call it. Sorry if this was my misunderstanding. I will remove it, and thank you for pointing it out.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think a slight difference adds any value without being able to edit the prompt. If we decide to add the prompt editing functionality, we can introduce the Regenerate button at that point. For now, I think it's sufficient to show only when the stream is stopped mid-generation.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I removed the Regenerate btn in case we have received a full response |
||
| {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', { | ||
| defaultMessage: 'Regenerate', | ||
| })} | ||
| </EuiButtonEmpty> | ||
| </EuiFlexItem> | ||
| <EuiFlexItem grow={false}> | ||
| <StartConversationButton onClick={handleStartConversation} /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can start a conversation when the summary is emtpy
Notice how an empty string is passed as the summary
Screen.Recording.2026-01-21.at.5.22.07.PM.movWe shouldn't allow starting a conversation when the summary is empty.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, @viduni94 ! So, when the user clicked "stop conversation" we should show only "Regenerate " btn (without "Start conversation"), right?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should definitely not show "Start conversation" when the summary is empty. When there's a partial result:
Maybe worth checking with @isaclfreire
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I definitely think that if the summary is empty, it doesn’t make sense to start the conversation. But when the summary is partial, I think it depends on which parts are available. I don’t have a strong opinion on this. I’d propose removing the “Start conversation” button when the summary is empty, and keeping it when the summary is partial. That said, I’m flexible and happy to change this now or later.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Screen.Recording.2026-01-22.at.20.02.37.mov
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good! Let's leave it this way and update later if it comes up. |
||
| </EuiFlexItem> | ||
| </EuiFlexGroup> | ||
| </> | ||
| ) : null} | ||
| </EuiPanel> | ||
| </EuiAccordion> | ||
| </EuiPanel> | ||
| ); | ||
|
|
||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OR you could consider splitting this into 2 tests and wrap in a describe block.
Example: