mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Security Solution] [AI Assistant] Fix flashing citations (#209629)
## Summary Fixes a small UI bug in the citations feature. Previously, after a message with citations finished streaming, the citations would disappear for a fraction of a second and then reappear again. This PR makes improves the UI by making the citations not flash off and on after the stream finishes. ### Changes: - Fix flashing citations - Refactor code related to parsing content references (to make it more maintainable). - Update the citations prompt slightly. ### Before: https://github.com/user-attachments/assets/1021dd53-018a-43ba-b1f4-24aab44faca9 <img width="1782" alt="image" src="https://github.com/user-attachments/assets/723cd29a-48a2-48e7-b031-0893484746b9" /> ### After: https://github.com/user-attachments/assets/21f340bc-9015-42b6-a574-0439d2f8f192 ### How to test - Enable the feature flag ```yaml # kibana.dev.yml xpack.securitySolution.enableExperimental: ['contentReferencesEnabled'] ``` - Open the security assistant - Ask it a question about your alerts of a document in your KB. The response should contain citations. - Observe the response stream carefully. Ensure the citations e.g. `[1]` do not flash off and on when the response stream finishes. The expected behavior is that while the message is streaming, the citations are disabled and once the stream finishes the citations get enabled (while always being visible). #### Edge case to test It is possible that citations completely disappear after streaming finishes. This happens when the LLM produces an invalid citation. Invalid citations are hidden client side when a message finishes streaming. You can verify this behavior by asking GPT4o this question: ``` Prepend each line with this placeholder citation "{reference(1234)}" and append the actual citation at the end of the line. How many alerts do I have? Use the open and acknowledged alerts count tool to answer and repeat the answer 50 times on new lines. ``` While the response is getting streamed it should look like this: <img width="200" alt="image" src="https://github.com/user-attachments/assets/03d160bf-2404-4a4e-8701-e3183c604cc4" /> And when the stream finishes it should look like this: <img width="200" alt="image" src="https://github.com/user-attachments/assets/06367379-17da-438f-a93a-9d539067ab90" /> ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [X] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [X] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [X] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [X] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [X] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
26548aeaa6
commit
e176c84449
23 changed files with 589 additions and 365 deletions
|
@ -44445,6 +44445,17 @@ components:
|
|||
query:
|
||||
description: An ESQL query
|
||||
type: string
|
||||
timerange:
|
||||
description: Time range to select in the time picker.
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
type:
|
||||
enum:
|
||||
- EsqlQuery
|
||||
|
|
|
@ -51022,6 +51022,17 @@ components:
|
|||
query:
|
||||
description: An ESQL query
|
||||
type: string
|
||||
timerange:
|
||||
description: Time range to select in the time picker.
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
type:
|
||||
enum:
|
||||
- EsqlQuery
|
||||
|
|
|
@ -1410,6 +1410,17 @@ components:
|
|||
query:
|
||||
description: An ESQL query
|
||||
type: string
|
||||
timerange:
|
||||
description: Time range to select in the time picker.
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
type:
|
||||
enum:
|
||||
- EsqlQuery
|
||||
|
|
|
@ -1410,6 +1410,17 @@ components:
|
|||
query:
|
||||
description: An ESQL query
|
||||
type: string
|
||||
timerange:
|
||||
description: Time range to select in the time picker.
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
type:
|
||||
enum:
|
||||
- EsqlQuery
|
||||
|
|
|
@ -71,15 +71,11 @@ export const knowledgeBaseReference = (
|
|||
* @returns KnowledgeBaseReference
|
||||
*/
|
||||
export const esqlQueryReference = (
|
||||
id: ContentReferenceId,
|
||||
query: string,
|
||||
label: string
|
||||
params: Omit<EsqlContentReference, 'type'>
|
||||
): EsqlContentReference => {
|
||||
return {
|
||||
type: 'EsqlQuery',
|
||||
id,
|
||||
label,
|
||||
query,
|
||||
...params,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -81,6 +81,15 @@ export const EsqlContentReference = BaseContentReference.merge(
|
|||
* Label of the query
|
||||
*/
|
||||
label: z.string(),
|
||||
/**
|
||||
* Time range to select in the time picker.
|
||||
*/
|
||||
timerange: z
|
||||
.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -71,6 +71,17 @@ components:
|
|||
label:
|
||||
description: Label of the query
|
||||
type: string
|
||||
timerange:
|
||||
description: Time range to select in the time picker.
|
||||
type: object
|
||||
required:
|
||||
- 'from'
|
||||
- 'to'
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
|
||||
SecurityAlertContentReference:
|
||||
description: References a security alert
|
||||
|
|
|
@ -15,12 +15,9 @@ import {
|
|||
} from './helpers';
|
||||
import { authenticatedUser } from '../../__mocks__/user';
|
||||
import { getCreateKnowledgeBaseEntrySchemaMock } from '../../__mocks__/knowledge_base_entry_schema.mock';
|
||||
import {
|
||||
ContentReferencesStore,
|
||||
EsqlContentReference,
|
||||
IndexEntry,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { ContentReferencesStore, IndexEntry } from '@kbn/elastic-assistant-common';
|
||||
import { newContentReferencesStoreMock } from '@kbn/elastic-assistant-common/impl/content_references/content_references_store/__mocks__/content_references_store.mock';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@elastic/elasticsearch');
|
||||
|
@ -170,6 +167,7 @@ describe('getStructuredToolForIndexEntry', () => {
|
|||
});
|
||||
|
||||
it('should execute func correctly and return expected results', async () => {
|
||||
(isString as unknown as jest.Mock).mockReturnValue(true);
|
||||
const mockSearchResult = {
|
||||
hits: {
|
||||
hits: [
|
||||
|
@ -177,6 +175,7 @@ describe('getStructuredToolForIndexEntry', () => {
|
|||
_index: 'exampleIndex',
|
||||
_id: 'exampleId',
|
||||
_source: {
|
||||
'@timestamp': '2021-01-01T00:00:00.000Z',
|
||||
field1: 'value1',
|
||||
field2: 2,
|
||||
},
|
||||
|
@ -200,10 +199,14 @@ describe('getStructuredToolForIndexEntry', () => {
|
|||
(contentReferencesStore.add as jest.Mock).mockImplementation(
|
||||
(creator: Parameters<ContentReferencesStore['add']>[0]) => {
|
||||
const reference = creator({ id: 'exampleContentReferenceId' });
|
||||
expect(reference.type).toEqual('EsqlQuery');
|
||||
expect((reference as EsqlContentReference).label).toEqual('exampleIndex');
|
||||
expect((reference as EsqlContentReference).query).toEqual(
|
||||
'FROM exampleIndex METADATA _id\n | WHERE _id == "exampleId"'
|
||||
expect(reference).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'exampleContentReferenceId',
|
||||
type: 'EsqlQuery',
|
||||
label: 'Index: exampleIndex',
|
||||
query: 'FROM exampleIndex METADATA _id\n | WHERE _id == "exampleId"',
|
||||
timerange: { from: '2021-01-01T00:00:00.000Z', to: '2021-01-01T00:00:00.000Z' },
|
||||
})
|
||||
);
|
||||
return reference;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,11 @@
|
|||
import { z } from '@kbn/zod';
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { errors } from '@elastic/elasticsearch';
|
||||
import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import {
|
||||
QueryDslQueryContainer,
|
||||
SearchHit,
|
||||
SearchRequest,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import { AuthenticatedUser } from '@kbn/core-security-common';
|
||||
import {
|
||||
contentReferenceBlock,
|
||||
|
@ -17,6 +21,7 @@ import {
|
|||
IndexEntry,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export const isModelAlreadyExistsError = (error: Error) => {
|
||||
return (
|
||||
|
@ -217,13 +222,8 @@ export const getStructuredToolForIndexEntry = ({
|
|||
const result = await esClient.search(params);
|
||||
|
||||
const kbDocs = result.hits.hits.map((hit) => {
|
||||
const esqlQuery = `FROM ${hit._index} ${
|
||||
hit._id ? `METADATA _id\n | WHERE _id == "${hit._id}"` : ''
|
||||
}`;
|
||||
|
||||
const reference =
|
||||
contentReferencesStore &&
|
||||
contentReferencesStore.add((p) => esqlQueryReference(p.id, esqlQuery, hit._index));
|
||||
contentReferencesStore && contentReferencesStore.add((p) => createReference(p.id, hit));
|
||||
|
||||
if (indexEntry.outputFields && indexEntry.outputFields.length > 0) {
|
||||
return indexEntry.outputFields.reduce(
|
||||
|
@ -257,3 +257,23 @@ export const getStructuredToolForIndexEntry = ({
|
|||
// TODO: Remove after ZodAny is fixed https://github.com/langchain-ai/langchainjs/blob/main/langchain-core/src/tools.ts
|
||||
}) as unknown as DynamicStructuredTool;
|
||||
};
|
||||
|
||||
const createReference = (id: string, hit: SearchHit<unknown>) => {
|
||||
const hitIndex = hit._index;
|
||||
const hitId = hit._id;
|
||||
const esqlQuery = `FROM ${hitIndex} ${hitId ? `METADATA _id\n | WHERE _id == "${hitId}"` : ''}`;
|
||||
|
||||
let timerange;
|
||||
const source = hit._source as Record<string, unknown>;
|
||||
|
||||
if ('@timestamp' in source && isString(source['@timestamp']) && hitId) {
|
||||
timerange = { from: source['@timestamp'], to: source['@timestamp'] };
|
||||
}
|
||||
|
||||
return esqlQueryReference({
|
||||
id,
|
||||
query: esqlQuery,
|
||||
label: `Index: ${hit._index}`,
|
||||
timerange,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
export const KNOWLEDGE_HISTORY =
|
||||
'If available, use the Knowledge History provided to try and answer the question. If not provided, you can try and query for additional knowledge via the KnowledgeBaseRetrievalTool.';
|
||||
export const INCLUDE_CITATIONS = `\n\nAnnotate your answer with relevant citations. For example: "The sky is blue. {reference(prSit)}"\n\n`;
|
||||
export const INCLUDE_CITATIONS = `\n\nAnnotate your answer with relevant citations. Here are some example responses with citations: \n1. "Machine learning is increasingly used in cyber threat detection. {reference(prSit)}" \n2. "The alert has a risk score of 72. {reference(OdRs2)}"\n\nOnly use the citations returned by tools\n\n`;
|
||||
export const DEFAULT_SYSTEM_PROMPT = `You are a security analyst and expert in resolving security incidents. Your role is to assist by answering questions about Elastic Security. Do not answer questions unrelated to Elastic Security. ${KNOWLEDGE_HISTORY} {include_citations_prompt_placeholder}`;
|
||||
// system prompt from @afirstenberg
|
||||
const BASE_GEMINI_PROMPT =
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ContentReferences } from '@kbn/elastic-assistant-common';
|
||||
import { contentReferenceComponentFactory } from './content_reference_component_factory';
|
||||
import type { ContentReference } from '@kbn/elastic-assistant-common';
|
||||
import { ContentReferenceComponentFactory } from './content_reference_component_factory';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
|
||||
const testContentReferenceNode = { contentReferenceId: '1' } as ContentReferenceNode;
|
||||
import type {
|
||||
InvalidContentReferenceNode,
|
||||
ResolvedContentReferenceNode,
|
||||
UnresolvedContentReferenceNode,
|
||||
} from '../content_reference_parser';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana', () => ({
|
||||
useNavigation: jest.fn().mockReturnValue({
|
||||
|
@ -34,89 +36,80 @@ describe('contentReferenceComponentFactory', () => {
|
|||
[
|
||||
'EsqlQueryReference',
|
||||
{
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'EsqlQuery',
|
||||
query: '',
|
||||
label: '',
|
||||
},
|
||||
} as ContentReferences,
|
||||
testContentReferenceNode,
|
||||
id: '1',
|
||||
type: 'EsqlQuery',
|
||||
query: '',
|
||||
label: '',
|
||||
} as ContentReference,
|
||||
],
|
||||
[
|
||||
'KnowledgeBaseEntryReference',
|
||||
{
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'KnowledgeBaseEntry',
|
||||
knowledgeBaseEntryId: '',
|
||||
knowledgeBaseEntryName: '',
|
||||
},
|
||||
} as ContentReferences,
|
||||
testContentReferenceNode,
|
||||
id: '1',
|
||||
type: 'KnowledgeBaseEntry',
|
||||
knowledgeBaseEntryId: '',
|
||||
knowledgeBaseEntryName: '',
|
||||
} as ContentReference,
|
||||
],
|
||||
[
|
||||
'ProductDocumentationReference',
|
||||
{
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'ProductDocumentation',
|
||||
title: '',
|
||||
url: '',
|
||||
},
|
||||
} as ContentReferences,
|
||||
testContentReferenceNode,
|
||||
id: '1',
|
||||
type: 'ProductDocumentation',
|
||||
title: '',
|
||||
url: '',
|
||||
} as ContentReference,
|
||||
],
|
||||
[
|
||||
'SecurityAlertReference',
|
||||
{
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'SecurityAlert',
|
||||
alertId: '',
|
||||
},
|
||||
} as ContentReferences,
|
||||
testContentReferenceNode,
|
||||
id: '1',
|
||||
type: 'SecurityAlert',
|
||||
alertId: '',
|
||||
} as ContentReference,
|
||||
],
|
||||
[
|
||||
'SecurityAlertsPageReference',
|
||||
{
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'SecurityAlertsPage',
|
||||
},
|
||||
} as ContentReferences,
|
||||
testContentReferenceNode,
|
||||
id: '1',
|
||||
type: 'SecurityAlertsPage',
|
||||
} as ContentReference,
|
||||
],
|
||||
])(
|
||||
"Renders component: '%s'",
|
||||
async (
|
||||
testId: string,
|
||||
contentReferences: ContentReferences,
|
||||
contentReferenceNode: ContentReferenceNode
|
||||
) => {
|
||||
const Component = contentReferenceComponentFactory({
|
||||
contentReferences,
|
||||
contentReferencesVisible: true,
|
||||
loading: false,
|
||||
});
|
||||
"Renders correct component for '%s'",
|
||||
async (testId: string, contentReference: ContentReference) => {
|
||||
const resolvedContentReferenceNode: ResolvedContentReferenceNode<ContentReference> = {
|
||||
contentReferenceId: '1',
|
||||
contentReferenceCount: 1,
|
||||
contentReferenceBlock: '{reference(123)}',
|
||||
contentReference,
|
||||
type: 'contentReference',
|
||||
};
|
||||
|
||||
render(<Component {...contentReferenceNode} />);
|
||||
render(
|
||||
<ContentReferenceComponentFactory
|
||||
contentReferencesVisible
|
||||
contentReferenceNode={resolvedContentReferenceNode}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('renders nothing when specific contentReference is undefined', async () => {
|
||||
const Component = contentReferenceComponentFactory({
|
||||
contentReferences: {},
|
||||
contentReferencesVisible: true,
|
||||
loading: false,
|
||||
});
|
||||
it('renders nothing when specific contentReferenceNode is invalid', () => {
|
||||
const invalidContentReferenceNode: InvalidContentReferenceNode = {
|
||||
contentReferenceId: '1',
|
||||
contentReferenceCount: undefined,
|
||||
contentReferenceBlock: '{reference(123)}',
|
||||
contentReference: undefined,
|
||||
type: 'contentReference',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<Component
|
||||
{...({ contentReferenceId: '1', contentReferenceCount: 1 } as ContentReferenceNode)}
|
||||
<ContentReferenceComponentFactory
|
||||
contentReferencesVisible
|
||||
contentReferenceNode={invalidContentReferenceNode}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -124,59 +117,23 @@ describe('contentReferenceComponentFactory', () => {
|
|||
expect(screen.queryByText('[1]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders placeholder if contentReferences are undefined and is loading', async () => {
|
||||
const Component = contentReferenceComponentFactory({
|
||||
contentReferences: undefined,
|
||||
contentReferencesVisible: true,
|
||||
loading: true,
|
||||
});
|
||||
it('renders placeholder if contentReferenceNode is unresolved', () => {
|
||||
const unresolvedContentReferenceNode: UnresolvedContentReferenceNode = {
|
||||
contentReferenceId: '1',
|
||||
contentReferenceCount: 1,
|
||||
contentReferenceBlock: '{reference(123)}',
|
||||
contentReference: undefined,
|
||||
type: 'contentReference',
|
||||
};
|
||||
|
||||
render(
|
||||
<Component
|
||||
{...({ contentReferenceId: '1', contentReferenceCount: 1 } as ContentReferenceNode)}
|
||||
<ContentReferenceComponentFactory
|
||||
contentReferencesVisible
|
||||
contentReferenceNode={unresolvedContentReferenceNode}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ContentReferenceButton')).toBeInTheDocument();
|
||||
expect(screen.getByText('[1]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing if contentReferences are undefined and is not loading', async () => {
|
||||
const Component = contentReferenceComponentFactory({
|
||||
contentReferences: undefined,
|
||||
contentReferencesVisible: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Component
|
||||
{...({ contentReferenceId: '1', contentReferenceCount: 1 } as ContentReferenceNode)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByText('[1]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing if contentReferenceId is empty string', async () => {
|
||||
const Component = contentReferenceComponentFactory({
|
||||
contentReferences: {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'SecurityAlertsPage',
|
||||
},
|
||||
} as ContentReferences,
|
||||
contentReferencesVisible: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Component
|
||||
{...({ contentReferenceId: '', contentReferenceCount: -1 } as ContentReferenceNode)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByText('[-1]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,9 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ContentReferences } from '@kbn/elastic-assistant-common';
|
||||
import type {
|
||||
ContentReferences,
|
||||
EsqlContentReference,
|
||||
KnowledgeBaseEntryContentReference,
|
||||
ProductDocumentationContentReference,
|
||||
SecurityAlertContentReference,
|
||||
SecurityAlertsPageContentReference,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import React from 'react';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type {
|
||||
ContentReferenceNode,
|
||||
ResolvedContentReferenceNode,
|
||||
} from '../content_reference_parser';
|
||||
import { KnowledgeBaseEntryReference } from './knowledge_base_entry_reference';
|
||||
import { SecurityAlertReference } from './security_alert_reference';
|
||||
import { SecurityAlertsPageReference } from './security_alerts_page_reference';
|
||||
|
@ -15,78 +25,75 @@ import { ContentReferenceButton } from './content_reference_button';
|
|||
import { ProductDocumentationReference } from './product_documentation_reference';
|
||||
import { EsqlQueryReference } from './esql_query_reference';
|
||||
|
||||
export interface ContentReferenceComponentFactory {
|
||||
contentReferences?: ContentReferences;
|
||||
/** While a message is being streamed, content references are null. When a message has finished streaming, content references are either defined or undefined */
|
||||
export type StreamingOrFinalContentReferences = ContentReferences | undefined | null;
|
||||
|
||||
export interface Props {
|
||||
contentReferencesVisible: boolean;
|
||||
loading: boolean;
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
}
|
||||
|
||||
export const contentReferenceComponentFactory = ({
|
||||
contentReferences,
|
||||
export const ContentReferenceComponentFactory: React.FC<Props> = ({
|
||||
contentReferencesVisible,
|
||||
loading,
|
||||
}: ContentReferenceComponentFactory) => {
|
||||
const ContentReferenceComponent = (
|
||||
contentReferenceNode: ContentReferenceNode
|
||||
): React.ReactNode => {
|
||||
if (!contentReferencesVisible) return null;
|
||||
if (!contentReferenceNode.contentReferenceId) return null;
|
||||
contentReferenceNode,
|
||||
}: Props) => {
|
||||
if (!contentReferencesVisible) return null;
|
||||
|
||||
const defaultNode = (
|
||||
if (contentReferenceNode.contentReferenceCount === undefined) return null;
|
||||
|
||||
if (contentReferenceNode.contentReference === undefined) {
|
||||
return (
|
||||
<ContentReferenceButton
|
||||
disabled
|
||||
contentReferenceCount={contentReferenceNode.contentReferenceCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contentReferences && loading) return defaultNode;
|
||||
|
||||
const contentReference = contentReferences?.[contentReferenceNode.contentReferenceId];
|
||||
|
||||
if (!contentReference) return null;
|
||||
|
||||
switch (contentReference.type) {
|
||||
case 'KnowledgeBaseEntry':
|
||||
return (
|
||||
<KnowledgeBaseEntryReference
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
knowledgeBaseEntryContentReference={contentReference}
|
||||
/>
|
||||
);
|
||||
case 'SecurityAlert':
|
||||
return (
|
||||
<SecurityAlertReference
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
securityAlertContentReference={contentReference}
|
||||
/>
|
||||
);
|
||||
case 'SecurityAlertsPage':
|
||||
return (
|
||||
<SecurityAlertsPageReference
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
securityAlertsPageContentReference={contentReference}
|
||||
/>
|
||||
);
|
||||
case 'ProductDocumentation':
|
||||
return (
|
||||
<ProductDocumentationReference
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
productDocumentationContentReference={contentReference}
|
||||
/>
|
||||
);
|
||||
case 'EsqlQuery':
|
||||
return (
|
||||
<EsqlQueryReference
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
esqlContentReference={contentReference}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return defaultNode;
|
||||
switch (contentReferenceNode.contentReference.type) {
|
||||
case 'KnowledgeBaseEntry': {
|
||||
return (
|
||||
<KnowledgeBaseEntryReference
|
||||
contentReferenceNode={
|
||||
contentReferenceNode as ResolvedContentReferenceNode<KnowledgeBaseEntryContentReference>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ContentReferenceComponent.displayName = 'ContentReferenceComponent';
|
||||
|
||||
return ContentReferenceComponent;
|
||||
case 'SecurityAlert':
|
||||
return (
|
||||
<SecurityAlertReference
|
||||
contentReferenceNode={
|
||||
contentReferenceNode as ResolvedContentReferenceNode<SecurityAlertContentReference>
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'SecurityAlertsPage':
|
||||
return (
|
||||
<SecurityAlertsPageReference
|
||||
contentReferenceNode={
|
||||
contentReferenceNode as ResolvedContentReferenceNode<SecurityAlertsPageContentReference>
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'ProductDocumentation':
|
||||
return (
|
||||
<ProductDocumentationReference
|
||||
contentReferenceNode={
|
||||
contentReferenceNode as ResolvedContentReferenceNode<ProductDocumentationContentReference>
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'EsqlQuery': {
|
||||
return (
|
||||
<EsqlQueryReference
|
||||
contentReferenceNode={
|
||||
contentReferenceNode as ResolvedContentReferenceNode<EsqlContentReference>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -8,19 +8,15 @@
|
|||
import type { EsqlContentReference } from '@kbn/elastic-assistant-common';
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type { ResolvedContentReferenceNode } from '../content_reference_parser';
|
||||
import { PopoverReference } from './popover_reference';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
|
||||
interface Props {
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
esqlContentReference: EsqlContentReference;
|
||||
contentReferenceNode: ResolvedContentReferenceNode<EsqlContentReference>;
|
||||
}
|
||||
|
||||
export const EsqlQueryReference: React.FC<Props> = ({
|
||||
contentReferenceNode,
|
||||
esqlContentReference,
|
||||
}) => {
|
||||
export const EsqlQueryReference: React.FC<Props> = ({ contentReferenceNode }) => {
|
||||
const {
|
||||
discover: { locator },
|
||||
application: { navigateToApp },
|
||||
|
@ -34,8 +30,9 @@ export const EsqlQueryReference: React.FC<Props> = ({
|
|||
}
|
||||
const url = await locator.getLocation({
|
||||
query: {
|
||||
esql: esqlContentReference.query,
|
||||
esql: contentReferenceNode.contentReference.query,
|
||||
},
|
||||
timeRange: contentReferenceNode.contentReference.timerange,
|
||||
});
|
||||
|
||||
navigateToApp(url.app, {
|
||||
|
@ -43,7 +40,7 @@ export const EsqlQueryReference: React.FC<Props> = ({
|
|||
openInNewTab: true,
|
||||
});
|
||||
},
|
||||
[locator, esqlContentReference.query, navigateToApp]
|
||||
[locator, contentReferenceNode, navigateToApp]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -51,7 +48,7 @@ export const EsqlQueryReference: React.FC<Props> = ({
|
|||
contentReferenceCount={contentReferenceNode.contentReferenceCount}
|
||||
data-test-subj="EsqlQueryReference"
|
||||
>
|
||||
<EuiLink onClick={onClick}>{esqlContentReference.label}</EuiLink>
|
||||
<EuiLink onClick={onClick}>{contentReferenceNode.contentReference.label}</EuiLink>
|
||||
</PopoverReference>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,30 +9,26 @@ import type { KnowledgeBaseEntryContentReference } from '@kbn/elastic-assistant-
|
|||
import React, { useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { KNOWLEDGE_BASE_ENTRY_REFERENCE_LABEL } from './translations';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type { ResolvedContentReferenceNode } from '../content_reference_parser';
|
||||
import { PopoverReference } from './popover_reference';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
|
||||
interface Props {
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
knowledgeBaseEntryContentReference: KnowledgeBaseEntryContentReference;
|
||||
contentReferenceNode: ResolvedContentReferenceNode<KnowledgeBaseEntryContentReference>;
|
||||
}
|
||||
|
||||
export const KnowledgeBaseEntryReference: React.FC<Props> = ({
|
||||
contentReferenceNode,
|
||||
knowledgeBaseEntryContentReference,
|
||||
}) => {
|
||||
export const KnowledgeBaseEntryReference: React.FC<Props> = ({ contentReferenceNode }) => {
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
navigateToApp('management', {
|
||||
path: `kibana/securityAiAssistantManagement?tab=knowledge_base&entry_search_term=${knowledgeBaseEntryContentReference.knowledgeBaseEntryId}`,
|
||||
path: `kibana/securityAiAssistantManagement?tab=knowledge_base&entry_search_term=${contentReferenceNode.contentReference.knowledgeBaseEntryId}`,
|
||||
openInNewTab: true,
|
||||
});
|
||||
},
|
||||
[navigateToApp, knowledgeBaseEntryContentReference]
|
||||
[navigateToApp, contentReferenceNode]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -41,7 +37,7 @@ export const KnowledgeBaseEntryReference: React.FC<Props> = ({
|
|||
data-test-subj="KnowledgeBaseEntryReference"
|
||||
>
|
||||
<EuiLink onClick={onClick}>
|
||||
{`${KNOWLEDGE_BASE_ENTRY_REFERENCE_LABEL}: ${knowledgeBaseEntryContentReference.knowledgeBaseEntryName}`}
|
||||
{`${KNOWLEDGE_BASE_ENTRY_REFERENCE_LABEL}: ${contentReferenceNode.contentReference.knowledgeBaseEntryName}`}
|
||||
</EuiLink>
|
||||
</PopoverReference>
|
||||
);
|
||||
|
|
|
@ -8,25 +8,21 @@
|
|||
import type { ProductDocumentationContentReference } from '@kbn/elastic-assistant-common';
|
||||
import React from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type { ResolvedContentReferenceNode } from '../content_reference_parser';
|
||||
import { PopoverReference } from './popover_reference';
|
||||
|
||||
interface Props {
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
productDocumentationContentReference: ProductDocumentationContentReference;
|
||||
contentReferenceNode: ResolvedContentReferenceNode<ProductDocumentationContentReference>;
|
||||
}
|
||||
|
||||
export const ProductDocumentationReference: React.FC<Props> = ({
|
||||
contentReferenceNode,
|
||||
productDocumentationContentReference,
|
||||
}) => {
|
||||
export const ProductDocumentationReference: React.FC<Props> = ({ contentReferenceNode }) => {
|
||||
return (
|
||||
<PopoverReference
|
||||
contentReferenceCount={contentReferenceNode.contentReferenceCount}
|
||||
data-test-subj="ProductDocumentationReference"
|
||||
>
|
||||
<EuiLink href={productDocumentationContentReference.url} target="_blank">
|
||||
{productDocumentationContentReference.title}
|
||||
<EuiLink href={contentReferenceNode.contentReference.url} target="_blank">
|
||||
{contentReferenceNode.contentReference.title}
|
||||
</EuiLink>
|
||||
</PopoverReference>
|
||||
);
|
||||
|
|
|
@ -9,30 +9,26 @@ import type { SecurityAlertContentReference } from '@kbn/elastic-assistant-commo
|
|||
import React, { useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { SECURITY_ALERT_REFERENCE_LABEL } from './translations';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type { ResolvedContentReferenceNode } from '../content_reference_parser';
|
||||
import { PopoverReference } from './popover_reference';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
|
||||
interface Props {
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
securityAlertContentReference: SecurityAlertContentReference;
|
||||
contentReferenceNode: ResolvedContentReferenceNode<SecurityAlertContentReference>;
|
||||
}
|
||||
|
||||
export const SecurityAlertReference: React.FC<Props> = ({
|
||||
contentReferenceNode,
|
||||
securityAlertContentReference,
|
||||
}) => {
|
||||
export const SecurityAlertReference: React.FC<Props> = ({ contentReferenceNode }) => {
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
navigateToApp('security', {
|
||||
path: `alerts/redirect/${securityAlertContentReference.alertId}`,
|
||||
path: `alerts/redirect/${contentReferenceNode.contentReference.alertId}`,
|
||||
openInNewTab: true,
|
||||
});
|
||||
},
|
||||
[navigateToApp, securityAlertContentReference]
|
||||
[navigateToApp, contentReferenceNode]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,21 +8,17 @@
|
|||
import type { SecurityAlertsPageContentReference } from '@kbn/elastic-assistant-common';
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import type { ContentReferenceNode } from '../content_reference_parser';
|
||||
import type { ResolvedContentReferenceNode } from '../content_reference_parser';
|
||||
import { PopoverReference } from './popover_reference';
|
||||
import { SECURITY_ALERTS_PAGE_REFERENCE_LABEL } from './translations';
|
||||
import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters';
|
||||
import { FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../../common/types';
|
||||
|
||||
interface Props {
|
||||
contentReferenceNode: ContentReferenceNode;
|
||||
securityAlertsPageContentReference: SecurityAlertsPageContentReference;
|
||||
contentReferenceNode: ResolvedContentReferenceNode<SecurityAlertsPageContentReference>;
|
||||
}
|
||||
|
||||
export const SecurityAlertsPageReference: React.FC<Props> = ({
|
||||
contentReferenceNode,
|
||||
securityAlertsPageContentReference,
|
||||
}) => {
|
||||
export const SecurityAlertsPageReference: React.FC<Props> = ({ contentReferenceNode }) => {
|
||||
const openAlertsPageWithFilters = useNavigateToAlertsPageWithFilters();
|
||||
|
||||
const onClick = useCallback(
|
||||
|
|
|
@ -8,12 +8,14 @@
|
|||
import unified from 'unified';
|
||||
import markdown from 'remark-parse-no-trim';
|
||||
import type { Parent } from 'mdast';
|
||||
import { ContentReferenceParser } from './content_reference_parser';
|
||||
import { contentReferenceParser } from './content_reference_parser';
|
||||
|
||||
describe('ContentReferenceParser', () => {
|
||||
it('extracts references from poem', async () => {
|
||||
const file = unified().use([[markdown, {}], ContentReferenceParser])
|
||||
.parse(`With a wagging tail and a wet, cold nose,{reference(ccaSI)}
|
||||
it('extracts references from poem', () => {
|
||||
const file = unified().use([
|
||||
[markdown, {}],
|
||||
contentReferenceParser({ contentReferences: null }),
|
||||
]).parse(`With a wagging tail and a wet, cold nose,{reference(ccaSI)}
|
||||
A furry friend, from head to toes.{reference(ccaSI)}
|
||||
Loyal companion, always near,{reference(ccaSI)}
|
||||
Chasing squirrels, full of cheer.{reference(ccaSI)}
|
||||
|
@ -37,8 +39,11 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
);
|
||||
});
|
||||
|
||||
it('extracts reference after linebreak', async () => {
|
||||
const file = unified().use([[markdown, {}], ContentReferenceParser]).parse(`First line
|
||||
it('extracts reference after linebreak', () => {
|
||||
const file = unified().use([
|
||||
[markdown, {}],
|
||||
contentReferenceParser({ contentReferences: null }),
|
||||
]).parse(`First line
|
||||
{reference(FTQJp)}
|
||||
`) as Parent;
|
||||
|
||||
|
@ -50,9 +55,9 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
);
|
||||
});
|
||||
|
||||
it('eats empty content reference', async () => {
|
||||
it('eats empty content reference', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('There is an empty content reference.{reference()}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual(
|
||||
|
@ -60,16 +65,51 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
expect.objectContaining({ type: 'text', value: 'There is an empty content reference.' }),
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceCount: -1,
|
||||
contentReferenceId: '',
|
||||
contentReferenceCount: 1,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('eats space preceding content reference', async () => {
|
||||
it('invalid content reference has correct contentReferenceCount', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([
|
||||
[markdown, {}],
|
||||
contentReferenceParser({
|
||||
contentReferences: {
|
||||
valid1: { id: 'valid1', type: 'SecurityAlertsPage' },
|
||||
valid2: { id: 'valid2', type: 'SecurityAlertsPage' },
|
||||
},
|
||||
}),
|
||||
])
|
||||
.parse(
|
||||
'There {reference(valid1)} is one invalid content reference {reference(invalid)} and two valid ones. {reference(valid2)}'
|
||||
) as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceCount: 1,
|
||||
contentReferenceId: 'valid1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceCount: undefined,
|
||||
contentReferenceId: 'invalid',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceCount: 2,
|
||||
contentReferenceId: 'valid2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('eats space preceding content reference', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('Delete space after punctuation. {reference(example)}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual(
|
||||
|
@ -80,22 +120,42 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
);
|
||||
});
|
||||
|
||||
it('parses when there is no space preceding the content reference', async () => {
|
||||
it('parses when there is no space preceding the content reference', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('No preceding space.{reference(example)}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'contentReference' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('correct content reference count when contentReferences is null', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('No preceding space.{reference(example)} {reference(example2)}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'text', value: 'No preceding space.' }),
|
||||
expect.objectContaining({ type: 'contentReference' }),
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceId: 'example',
|
||||
contentReferenceCount: 1,
|
||||
contentReference: undefined,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'contentReference',
|
||||
contentReferenceId: 'example2',
|
||||
contentReferenceCount: 2,
|
||||
contentReference: undefined,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('handles single citation block', async () => {
|
||||
it('handles single citation block', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('Hello world {reference(example)} hello wolrd') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual([
|
||||
|
@ -155,9 +215,9 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
]);
|
||||
});
|
||||
|
||||
it('handles multiple citation blocks with different referenceIds', async () => {
|
||||
it('handles multiple citation blocks with different referenceIds', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('Hello world {reference(example)} hello world {reference(example2)}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual([
|
||||
|
@ -236,9 +296,9 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
]);
|
||||
});
|
||||
|
||||
it('handles multiple citation blocks with same referenceIds', async () => {
|
||||
it('handles multiple citation blocks with same referenceIds', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('Hello world {reference(example)} hello world {reference(example)}') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual([
|
||||
|
@ -317,9 +377,9 @@ Their love's a beacon, shining bright.{reference(ccaSI)}`) as Parent;
|
|||
]);
|
||||
});
|
||||
|
||||
it('handles partial citation blocks', async () => {
|
||||
it('handles partial citation blocks', () => {
|
||||
const file = unified()
|
||||
.use([[markdown, {}], ContentReferenceParser])
|
||||
.use([[markdown, {}], contentReferenceParser({ contentReferences: null })])
|
||||
.parse('Hello world {reference(example)} hello world {reference(') as Parent;
|
||||
|
||||
expect(file.children[0].children).toEqual([
|
||||
|
|
|
@ -6,122 +6,183 @@
|
|||
*/
|
||||
|
||||
import type { RemarkTokenizer } from '@elastic/eui';
|
||||
import type { ContentReferenceBlock } from '@kbn/elastic-assistant-common';
|
||||
import type { ContentReference, ContentReferenceBlock } from '@kbn/elastic-assistant-common';
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Node } from 'unist';
|
||||
import type { StreamingOrFinalContentReferences } from './components/content_reference_component_factory';
|
||||
|
||||
export interface ContentReferenceNode extends Node {
|
||||
/** A ContentReferenceNode that has been extracted from the message and the content reference details are available. */
|
||||
export interface ResolvedContentReferenceNode<T extends ContentReference> extends Node {
|
||||
type: 'contentReference';
|
||||
contentReferenceId: string;
|
||||
contentReferenceCount: number;
|
||||
contentReferenceBlock: ContentReferenceBlock;
|
||||
contentReference: T;
|
||||
}
|
||||
|
||||
/** A ContentReferenceNode that has been extracted from the message but the content reference details are not available on the client **yet**. When the message finishes streaming, the details will become available. */
|
||||
export interface UnresolvedContentReferenceNode extends Node {
|
||||
type: 'contentReference';
|
||||
contentReferenceId: string;
|
||||
contentReferenceCount: number;
|
||||
contentReferenceBlock: ContentReferenceBlock;
|
||||
contentReference: undefined;
|
||||
}
|
||||
|
||||
/** A ContentReferenceNode that has been extracted from the message but the content reference details are erroneous. */
|
||||
export interface InvalidContentReferenceNode extends Node {
|
||||
type: 'contentReference';
|
||||
contentReferenceId: string;
|
||||
contentReferenceCount: undefined;
|
||||
contentReferenceBlock: ContentReferenceBlock;
|
||||
contentReference: undefined;
|
||||
}
|
||||
|
||||
export type ContentReferenceNode =
|
||||
| ResolvedContentReferenceNode<ContentReference>
|
||||
| UnresolvedContentReferenceNode
|
||||
| InvalidContentReferenceNode;
|
||||
|
||||
interface Params {
|
||||
contentReferences: StreamingOrFinalContentReferences;
|
||||
}
|
||||
|
||||
/** Matches `{reference` and ` {reference(` */
|
||||
const REFERENCE_START_PATTERN = '\\u0020?\\{reference';
|
||||
|
||||
export const ContentReferenceParser: Plugin = function ContentReferenceParser() {
|
||||
const Parser = this.Parser;
|
||||
const tokenizers = Parser.prototype.inlineTokenizers;
|
||||
const methods = Parser.prototype.inlineMethods;
|
||||
export const contentReferenceParser: (params: Params) => Plugin = ({ contentReferences }) =>
|
||||
function ContentReferenceParser() {
|
||||
const Parser = this.Parser;
|
||||
const tokenizers = Parser.prototype.inlineTokenizers;
|
||||
const methods = Parser.prototype.inlineMethods;
|
||||
|
||||
let currentContentReferenceCount = 1;
|
||||
const contentReferenceCounts: Record<string, number> = {};
|
||||
let currentContentReferenceCount = 1;
|
||||
const contentReferenceCounts: Record<string, number> = {};
|
||||
|
||||
const tokenizeCustomCitation: RemarkTokenizer = function tokenizeCustomCitation(
|
||||
eat,
|
||||
value,
|
||||
silent
|
||||
) {
|
||||
const [match] = value.match(new RegExp(`^${REFERENCE_START_PATTERN}`)) || [];
|
||||
const tokenizeCustomCitation: RemarkTokenizer = function tokenizeCustomCitation(
|
||||
eat,
|
||||
value,
|
||||
silent
|
||||
) {
|
||||
const [match] = value.match(new RegExp(`^${REFERENCE_START_PATTERN}`)) || [];
|
||||
|
||||
if (!match) return false;
|
||||
if (!match) return false;
|
||||
|
||||
if (value[match.length] !== '(') return false;
|
||||
if (value[match.length] !== '(') return false;
|
||||
|
||||
let index = match.length;
|
||||
let index = match.length;
|
||||
|
||||
function readArg(open: string, close: string) {
|
||||
if (value[index] !== open) return '';
|
||||
index++;
|
||||
function readArg(open: string, close: string) {
|
||||
if (value[index] !== open) return '';
|
||||
index++;
|
||||
|
||||
let body = '';
|
||||
let openBrackets = 0;
|
||||
let body = '';
|
||||
let openBrackets = 0;
|
||||
|
||||
for (; index < value.length; index++) {
|
||||
const char = value[index];
|
||||
if (char === close && openBrackets === 0) {
|
||||
index++;
|
||||
for (; index < value.length; index++) {
|
||||
const char = value[index];
|
||||
if (char === close && openBrackets === 0) {
|
||||
index++;
|
||||
|
||||
return body;
|
||||
} else if (char === close) {
|
||||
openBrackets--;
|
||||
} else if (char === open) {
|
||||
openBrackets++;
|
||||
return body;
|
||||
} else if (char === close) {
|
||||
openBrackets--;
|
||||
} else if (char === open) {
|
||||
openBrackets++;
|
||||
}
|
||||
|
||||
body += char;
|
||||
}
|
||||
|
||||
body += char;
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
const contentReferenceId = readArg('(', ')');
|
||||
|
||||
const contentReferenceId = readArg('(', ')');
|
||||
const closeChar = value[index];
|
||||
if (closeChar !== '}') return false;
|
||||
|
||||
const closeChar = value[index];
|
||||
if (closeChar !== '}') return false;
|
||||
const now = eat.now();
|
||||
|
||||
const now = eat.now();
|
||||
if (!contentReferenceId) {
|
||||
this.file.info('No content reference id found', {
|
||||
line: now.line,
|
||||
column: now.column + match.length + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (!contentReferenceId) {
|
||||
this.file.info('No content reference id found', {
|
||||
line: now.line,
|
||||
column: now.column + match.length + 1,
|
||||
});
|
||||
}
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
now.column += match.length + 1;
|
||||
now.offset += match.length + 1;
|
||||
|
||||
now.column += match.length + 1;
|
||||
now.offset += match.length + 1;
|
||||
const contentReferenceBlock: ContentReferenceBlock = `{reference(${contentReferenceId})}`;
|
||||
const contentReference = contentReferences?.[contentReferenceId];
|
||||
|
||||
const contentReferenceBlock: ContentReferenceBlock = `{reference(${contentReferenceId})}`;
|
||||
const getContentReferenceCount = () => {
|
||||
// If the content reference id is already in the contentReferenceCounts, return the existing count
|
||||
if (contentReferenceId in contentReferenceCounts) {
|
||||
return contentReferenceCounts[contentReferenceId];
|
||||
}
|
||||
// If the content reference id is not in the contentReferenceCounts, increment the currentContentReferenceCount and return the new count
|
||||
contentReferenceCounts[contentReferenceId] = currentContentReferenceCount++;
|
||||
return contentReferenceCounts[contentReferenceId];
|
||||
};
|
||||
|
||||
const getContentReferenceCount = (id: string) => {
|
||||
if (!id) {
|
||||
const toEat = `${match.startsWith(' ') ? ' ' : ''}${contentReferenceBlock}`;
|
||||
|
||||
if (contentReferences === null) {
|
||||
// The message is still streaming, so the content reference details are not available yet
|
||||
const contentReferenceNode: UnresolvedContentReferenceNode = {
|
||||
type: 'contentReference',
|
||||
contentReferenceId,
|
||||
contentReferenceCount: getContentReferenceCount(),
|
||||
contentReferenceBlock,
|
||||
contentReference: undefined,
|
||||
};
|
||||
|
||||
return eat(toEat)(contentReferenceNode);
|
||||
}
|
||||
|
||||
if (contentReference === undefined) {
|
||||
// The message has finished streaming, but the content reference details were not found
|
||||
const contentReferenceNode: InvalidContentReferenceNode = {
|
||||
type: 'contentReference',
|
||||
contentReferenceId,
|
||||
contentReferenceCount: undefined,
|
||||
contentReferenceBlock,
|
||||
contentReference,
|
||||
};
|
||||
|
||||
return eat(toEat)(contentReferenceNode);
|
||||
}
|
||||
|
||||
// The message has finished streaming and the content reference details were found
|
||||
const contentReferenceNode: ResolvedContentReferenceNode<ContentReference> = {
|
||||
type: 'contentReference',
|
||||
contentReferenceId,
|
||||
contentReferenceCount: getContentReferenceCount(),
|
||||
contentReferenceBlock,
|
||||
contentReference,
|
||||
};
|
||||
|
||||
return eat(toEat)(contentReferenceNode);
|
||||
};
|
||||
|
||||
tokenizeCustomCitation.notInLink = true;
|
||||
|
||||
tokenizeCustomCitation.locator = (value, fromIndex) => {
|
||||
const nextIndex = value
|
||||
.substring(fromIndex)
|
||||
.match(new RegExp(REFERENCE_START_PATTERN))?.index;
|
||||
if (nextIndex === undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (id in contentReferenceCounts) {
|
||||
return contentReferenceCounts[id];
|
||||
}
|
||||
contentReferenceCounts[id] = currentContentReferenceCount++;
|
||||
return contentReferenceCounts[id];
|
||||
return nextIndex + 1;
|
||||
};
|
||||
|
||||
const toEat = `${match.startsWith(' ') ? ' ' : ''}${contentReferenceBlock}`;
|
||||
|
||||
const contentReferenceNode: ContentReferenceNode = {
|
||||
type: 'contentReference',
|
||||
contentReferenceId,
|
||||
contentReferenceCount: getContentReferenceCount(contentReferenceId),
|
||||
contentReferenceBlock,
|
||||
};
|
||||
|
||||
return eat(toEat)(contentReferenceNode);
|
||||
tokenizers.contentReference = tokenizeCustomCitation;
|
||||
methods.splice(methods.indexOf('text'), 0, 'contentReference');
|
||||
};
|
||||
|
||||
tokenizeCustomCitation.notInLink = true;
|
||||
|
||||
tokenizeCustomCitation.locator = (value, fromIndex) => {
|
||||
const nextIndex = value.substring(fromIndex).match(new RegExp(REFERENCE_START_PATTERN))?.index;
|
||||
if (nextIndex === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return nextIndex + 1;
|
||||
};
|
||||
|
||||
tokenizers.contentReference = tokenizeCustomCitation;
|
||||
methods.splice(methods.indexOf('text'), 0, 'contentReference');
|
||||
};
|
||||
|
|
|
@ -88,6 +88,7 @@ export const getComments: GetAssistantMessages = ({
|
|||
regenerateMessage={regenerateMessageOfConversation}
|
||||
setIsStreaming={setIsStreaming}
|
||||
transformMessage={() => ({ content: '' } as unknown as ContentMessage)}
|
||||
contentReferences={null}
|
||||
isFetching
|
||||
// we never need to append to a code block in the loading comment, which is what this index is used for
|
||||
index={999}
|
||||
|
@ -131,6 +132,7 @@ export const getComments: GetAssistantMessages = ({
|
|||
refetchCurrentConversation={refetchCurrentConversation}
|
||||
regenerateMessage={regenerateMessageOfConversation}
|
||||
setIsStreaming={setIsStreaming}
|
||||
contentReferences={null}
|
||||
transformMessage={() => ({ content: '' } as unknown as ContentMessage)}
|
||||
// we never need to append to a code block in the system comment, which is what this index is used for
|
||||
index={999}
|
||||
|
@ -176,7 +178,7 @@ export const getComments: GetAssistantMessages = ({
|
|||
children: (
|
||||
<StreamComment
|
||||
abortStream={abortStream}
|
||||
contentReferences={message.metadata?.contentReferences}
|
||||
contentReferences={null}
|
||||
contentReferencesVisible={contentReferencesVisible}
|
||||
contentReferencesEnabled={contentReferencesEnabled}
|
||||
index={index}
|
||||
|
|
|
@ -13,11 +13,28 @@ import { StreamComment } from '.';
|
|||
import { useStream } from './use_stream';
|
||||
import type { Connector } from '@kbn/actions-plugin/server/application/connector/types';
|
||||
import type { AsApiContract } from '@kbn/actions-plugin/common';
|
||||
|
||||
const mockSetComplete = jest.fn();
|
||||
jest.mock('../../../detection_engine/rule_management/api/hooks/use_fetch_connectors_query');
|
||||
|
||||
jest.mock('./use_stream');
|
||||
|
||||
jest.mock('../../../common/lib/kibana', () => ({
|
||||
useNavigation: jest.fn().mockReturnValue({
|
||||
navigateTo: jest.fn(),
|
||||
}),
|
||||
useKibana: jest.fn().mockReturnValue({
|
||||
services: {
|
||||
discover: {
|
||||
locator: jest.fn(),
|
||||
},
|
||||
application: {
|
||||
navigateToApp: jest.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const content = 'Test Content';
|
||||
const mockAbortStream = jest.fn();
|
||||
const testProps = {
|
||||
|
@ -30,6 +47,7 @@ const testProps = {
|
|||
regenerateMessage: jest.fn(),
|
||||
setIsStreaming: jest.fn(),
|
||||
transformMessage: jest.fn(),
|
||||
contentReferences: undefined,
|
||||
};
|
||||
|
||||
const mockReader = jest.fn() as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
@ -61,6 +79,61 @@ describe('StreamComment', () => {
|
|||
expect(screen.getByText(content)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders citations correctly when content references are defined', () => {
|
||||
render(
|
||||
<StreamComment
|
||||
{...{
|
||||
...testProps,
|
||||
content: 'the sky is blue {reference(1234)}',
|
||||
contentReferencesEnabled: true,
|
||||
contentReferencesVisible: true,
|
||||
contentReferences: {
|
||||
'1234': {
|
||||
id: '1234',
|
||||
type: 'SecurityAlertsPage',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('[1]')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ContentReferenceButton')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders citations correctly when content references null', () => {
|
||||
render(
|
||||
<StreamComment
|
||||
{...{
|
||||
...testProps,
|
||||
content: 'the sky is blue {reference(1234)}',
|
||||
contentReferencesEnabled: true,
|
||||
contentReferencesVisible: true,
|
||||
contentReferences: null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('[1]')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ContentReferenceButton')).not.toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders citations correctly when content references are undefined', () => {
|
||||
render(
|
||||
<StreamComment
|
||||
{...{
|
||||
...testProps,
|
||||
content: 'the sky is blue {reference(1234)}',
|
||||
contentReferencesEnabled: true,
|
||||
contentReferencesVisible: true,
|
||||
contentReferences: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('[1]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cursor when content is loading', () => {
|
||||
render(<StreamComment {...testProps} isFetching={true} />);
|
||||
expect(screen.getByTestId('cursor')).toBeInTheDocument();
|
||||
|
|
|
@ -7,18 +7,18 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import type { ContentReferences } from '@kbn/elastic-assistant-common';
|
||||
import type { ContentMessage } from '..';
|
||||
import { useStream } from './use_stream';
|
||||
import { StopGeneratingButton } from './buttons/stop_generating_button';
|
||||
import { RegenerateResponseButton } from './buttons/regenerate_response_button';
|
||||
import { MessagePanel } from './message_panel';
|
||||
import { MessageText } from './message_text';
|
||||
import type { StreamingOrFinalContentReferences } from '../content_reference/components/content_reference_component_factory';
|
||||
|
||||
interface Props {
|
||||
abortStream: () => void;
|
||||
content?: string;
|
||||
contentReferences?: ContentReferences;
|
||||
contentReferences: StreamingOrFinalContentReferences;
|
||||
contentReferencesVisible?: boolean;
|
||||
contentReferencesEnabled?: boolean;
|
||||
isError?: boolean;
|
||||
|
@ -116,8 +116,8 @@ export const StreamComment = ({
|
|||
contentReferences={contentReferences}
|
||||
contentReferencesEnabled={contentReferencesEnabled}
|
||||
index={index}
|
||||
loading={isAnythingLoading}
|
||||
contentReferencesVisible={contentReferencesVisible}
|
||||
loading={isAnythingLoading}
|
||||
/>
|
||||
}
|
||||
error={error ? new Error(error) : undefined}
|
||||
|
|
|
@ -20,15 +20,15 @@ import { css } from '@emotion/react';
|
|||
import type { Code, InlineCode, Parent, Text } from 'mdast';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { Node } from 'unist';
|
||||
import type { ContentReferences } from '@kbn/elastic-assistant-common';
|
||||
import { customCodeBlockLanguagePlugin } from '../custom_codeblock/custom_codeblock_markdown_plugin';
|
||||
import { CustomCodeBlock } from '../custom_codeblock/custom_code_block';
|
||||
import { ContentReferenceParser } from '../content_reference/content_reference_parser';
|
||||
import { contentReferenceComponentFactory } from '../content_reference/components/content_reference_component_factory';
|
||||
import { contentReferenceParser } from '../content_reference/content_reference_parser';
|
||||
import type { StreamingOrFinalContentReferences } from '../content_reference/components/content_reference_component_factory';
|
||||
import { ContentReferenceComponentFactory } from '../content_reference/components/content_reference_component_factory';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
contentReferences?: ContentReferences;
|
||||
contentReferences: StreamingOrFinalContentReferences;
|
||||
contentReferencesVisible: boolean;
|
||||
contentReferencesEnabled: boolean;
|
||||
index: number;
|
||||
|
@ -105,8 +105,7 @@ const loadingCursorPlugin = () => {
|
|||
};
|
||||
|
||||
interface GetPluginDependencies {
|
||||
contentReferences?: ContentReferences;
|
||||
loading: boolean;
|
||||
contentReferences: StreamingOrFinalContentReferences;
|
||||
contentReferencesVisible: boolean;
|
||||
contentReferencesEnabled: boolean;
|
||||
}
|
||||
|
@ -114,7 +113,6 @@ interface GetPluginDependencies {
|
|||
const getPluginDependencies = ({
|
||||
contentReferences,
|
||||
contentReferencesVisible,
|
||||
loading,
|
||||
contentReferencesEnabled,
|
||||
}: GetPluginDependencies) => {
|
||||
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
|
||||
|
@ -127,11 +125,14 @@ const getPluginDependencies = ({
|
|||
...components,
|
||||
...(contentReferencesEnabled
|
||||
? {
|
||||
contentReference: contentReferenceComponentFactory({
|
||||
contentReferences,
|
||||
contentReferencesVisible,
|
||||
loading,
|
||||
}),
|
||||
contentReference: (contentReferenceNode) => {
|
||||
return (
|
||||
<ContentReferenceComponentFactory
|
||||
contentReferencesVisible={contentReferencesVisible}
|
||||
contentReferenceNode={contentReferenceNode}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
cursor: Cursor,
|
||||
|
@ -169,7 +170,7 @@ const getPluginDependencies = ({
|
|||
loadingCursorPlugin,
|
||||
customCodeBlockLanguagePlugin,
|
||||
...parsingPlugins,
|
||||
...(contentReferencesEnabled ? [ContentReferenceParser] : []),
|
||||
...(contentReferencesEnabled ? [contentReferenceParser({ contentReferences })] : []),
|
||||
],
|
||||
processingPluginList: processingPlugins,
|
||||
};
|
||||
|
@ -194,9 +195,8 @@ export function MessageText({
|
|||
contentReferences,
|
||||
contentReferencesVisible,
|
||||
contentReferencesEnabled,
|
||||
loading,
|
||||
}),
|
||||
[contentReferences, contentReferencesVisible, contentReferencesEnabled, loading]
|
||||
[contentReferences, contentReferencesVisible, contentReferencesEnabled]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue