diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx
index 730c88413833d..09b186903c0bd 100644
--- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx
+++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx
@@ -88,4 +88,34 @@ describe('NewChat', () => {
expect(mockUseAssistantOverlay.showAssistantOverlay).toHaveBeenCalledWith(true);
});
+
+ it('renders new chat as link', () => {
+ render();
+
+ const newChatLink = screen.getByTestId('newChatLink');
+
+ expect(newChatLink).toBeInTheDocument();
+ });
+
+ it('calls onShowOverlay callback on click', () => {
+ const onShowOverlaySpy = jest.fn();
+ render();
+
+ const newChatButton = screen.getByTestId('newChat');
+
+ userEvent.click(newChatButton);
+
+ expect(onShowOverlaySpy).toHaveBeenCalled();
+ });
+
+ it('calls onShowOverlay callback on click for link', () => {
+ const onShowOverlaySpy = jest.fn();
+ render();
+
+ const newChatLink = screen.getByTestId('newChatLink');
+
+ userEvent.click(newChatLink);
+
+ expect(onShowOverlaySpy).toHaveBeenCalled();
+ });
});
diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx
index a4793dfd25a9d..d45f94b7d0b49 100644
--- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx
+++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { EuiButtonEmpty } from '@elastic/eui';
+import { EuiButtonEmpty, EuiLink } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { PromptContext } from '../assistant/prompt_context/types';
@@ -17,7 +17,7 @@ export type Props = Omit & {
children?: React.ReactNode;
/** Optionally automatically add this context to a conversation when the assistant is shown */
conversationId?: string;
- /** Defaults to `discuss`. If null, the button will not have an icon */
+ /** Defaults to `discuss`. If null, the button will not have an icon. Not available for link */
iconType?: string | null;
/** Optionally specify a well known ID, or default to a UUID */
promptContextId?: string;
@@ -25,6 +25,10 @@ export type Props = Omit & {
color?: 'text' | 'accent' | 'primary' | 'success' | 'warning' | 'danger';
/** Required to identify the availability of the Assistant for the current license level */
isAssistantEnabled: boolean;
+ /** Optionally render new chat as a link */
+ asLink?: boolean;
+ /** Optional callback when overlay shows */
+ onShowOverlay?: () => void;
};
const NewChatComponent: React.FC = ({
@@ -39,6 +43,8 @@ const NewChatComponent: React.FC = ({
suggestedUserPrompt,
tooltip,
isAssistantEnabled,
+ asLink = false,
+ onShowOverlay,
}) => {
const { showAssistantOverlay } = useAssistantOverlay(
category,
@@ -53,7 +59,8 @@ const NewChatComponent: React.FC = ({
const showOverlay = useCallback(() => {
showAssistantOverlay(true);
- }, [showAssistantOverlay]);
+ onShowOverlay?.();
+ }, [showAssistantOverlay, onShowOverlay]);
const icon = useMemo(() => {
if (iconType === null) {
@@ -64,12 +71,22 @@ const NewChatComponent: React.FC = ({
}, [iconType]);
return useMemo(
- () => (
-
- {children}
-
- ),
- [children, icon, showOverlay, color]
+ () =>
+ asLink ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ ),
+ [children, icon, showOverlay, color, asLink]
);
};
diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts
index 53c5bdd8a657e..526e9943ce703 100644
--- a/x-pack/plugins/security_solution/common/experimental_features.ts
+++ b/x-pack/plugins/security_solution/common/experimental_features.ts
@@ -153,6 +153,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
protectionUpdatesEnabled: true,
+ /**
+ * Enables AI assistant on rule creation form when query has error
+ */
+ AIAssistantOnRuleCreationFormEnabled: false,
+
/**
* Disables the timeline save tour.
* This flag is used to disable the tour in cypress tests.
diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts
index d16fd182928de..4d7c8f180d0ec 100644
--- a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts
+++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts
@@ -46,6 +46,9 @@ export enum TELEMETRY_EVENT {
ADD_INVESTIGATION_FIELDS = 'add_investigation_fields',
SET_INVESTIGATION_FIELDS = 'set_investigation_fields',
DELETE_INVESTIGATION_FIELDS = 'delete_investigation_fields',
+
+ // AI assistant on rule creation form
+ OPEN_ASSISTANT_ON_RULE_QUERY_ERROR = 'open_assistant_on_rule_query_error',
}
export enum TelemetryEventTypes {
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts
index 1f0bcb6596b40..484f78c53f0e0 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts
@@ -66,7 +66,7 @@ export const esqlValidator = async (
if (isMissingMetadataOperator) {
return {
code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT,
- message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR,
+ message: i18n.ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR,
};
}
@@ -84,7 +84,7 @@ export const esqlValidator = async (
if (!isEsqlQueryAggregating && !isIdFieldPresent) {
return {
code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT,
- message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR,
+ message: i18n.ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR,
};
}
} catch (error) {
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts
index 21dbb474b1235..245924b3d7e0a 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts
@@ -20,8 +20,15 @@ export const esqlValidationErrorMessage = (message: string) =>
defaultMessage: 'Error validating ES|QL: "{message}"',
});
-export const ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR = i18n.translate(
- 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError',
+export const ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.esqlValidation.missingMetadataOperatorInQueryError',
+ {
+ defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* [metadata _id, _version, _index].`,
+ }
+);
+
+export const ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdFieldInQueryError',
{
defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* metadata _id, _version, _index. In addition, the metadata properties (_id, _version, and _index) must be returned in the query response.`,
}
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx
new file mode 100644
index 0000000000000..9ec6cba770c71
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 { screen, render } from '@testing-library/react';
+
+import { TestProviders } from '../../../../common/mock';
+import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
+
+import { AiAssistant } from '.';
+
+jest.mock('../../../../assistant/use_assistant_availability', () => ({
+ useAssistantAvailability: jest.fn(),
+}));
+
+const useAssistantAvailabilityMock = useAssistantAvailability as jest.Mock;
+
+describe('AiAssistant', () => {
+ beforeEach(() => {
+ useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: true });
+ });
+ it('does not render chat component when does not have hasAssistantPrivilege', () => {
+ useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: false });
+
+ const { container } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(container).toBeEmptyDOMElement();
+ });
+ it('renders chat component when has hasAssistantPrivilege', () => {
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(screen.getByTestId('newChatLink')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx
new file mode 100644
index 0000000000000..83ce75d53f79f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx
@@ -0,0 +1,106 @@
+/*
+ * 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, { useCallback } from 'react';
+import { EuiSpacer } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+
+import { NewChat, AssistantAvatar } from '@kbn/elastic-assistant';
+
+import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../common/lib/telemetry';
+import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
+import * as i18nAssistant from '../../../../detections/pages/detection_engine/rules/translations';
+import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types';
+import type { FormHook, ValidationError } from '../../../../shared_imports';
+
+import * as i18n from './translations';
+
+const getLanguageName = (language: string | undefined) => {
+ let modifiedLanguage = language;
+ if (language === 'eql') {
+ modifiedLanguage = 'EQL(Event Query Language)';
+ }
+ if (language === 'esql') {
+ modifiedLanguage = 'ES|QL(The Elasticsearch Query Language)';
+ }
+
+ return modifiedLanguage;
+};
+
+const retrieveErrorMessages = (errors: ValidationError[]): string =>
+ errors
+ .flatMap(({ message, messages }) => [message, ...(Array.isArray(messages) ? messages : [])])
+ .join(', ');
+
+interface AiAssistantProps {
+ getFields: FormHook['getFields'];
+ language?: string | undefined;
+}
+
+const AiAssistantComponent: React.FC = ({ getFields, language }) => {
+ const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability();
+
+ const languageName = getLanguageName(language);
+
+ const getPromptContext = useCallback(async () => {
+ const queryField = getFields().queryBar;
+ const { query } = (queryField.value as DefineStepRule['queryBar']).query;
+
+ if (!query) {
+ return '';
+ }
+
+ if (queryField.errors.length === 0) {
+ return `No errors in ${languageName} language query detected. Current query: ${query.trim()}`;
+ }
+
+ return `${languageName} language query written for Elastic Security Detection rules: \"${query.trim()}\"
+returns validation error on form: \"${retrieveErrorMessages(queryField.errors)}\"
+Fix ${languageName} language query and give an example of it in markdown format that can be copied.
+Proposed solution should be valid and must not contain new line symbols (\\n)`;
+ }, [getFields, languageName]);
+
+ const onShowOverlay = useCallback(() => {
+ track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.OPEN_ASSISTANT_ON_RULE_QUERY_ERROR);
+ }, []);
+
+ if (!hasAssistantPrivilege) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ {i18n.ASK_ASSISTANT_ERROR_BUTTON}
+
+ ),
+ }}
+ />
+ >
+ );
+};
+
+export const AiAssistant = React.memo(AiAssistantComponent);
+AiAssistant.displayName = 'AiAssistant';
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts
new file mode 100644
index 0000000000000..f80f108aefdab
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const ASK_ASSISTANT_ERROR_BUTTON = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistant',
+ {
+ defaultMessage: 'Ask Assistant',
+ }
+);
+
+export const ASK_ASSISTANT_DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantDesc',
+ {
+ defaultMessage: 'Rule query error',
+ }
+);
+
+export const ASK_ASSISTANT_USER_PROMPT = (language: string | undefined) =>
+ i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantUserPrompt',
+ {
+ defaultMessage:
+ 'Explain all the errors present in the {language} query above. Generate a new working query, making sure all the errors are resolved in your response.',
+ values: { language },
+ }
+ );
+
+export const ASK_ASSISTANT_TOOLTIP = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantToolTip',
+ {
+ defaultMessage: 'Fix query or generate new one',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx
index f9ef3a1cf4643..3a0573f3f9b75 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx
@@ -10,6 +10,7 @@ import { screen, fireEvent, render, within, act, waitFor } from '@testing-librar
import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types';
import type { DataViewBase } from '@kbn/es-query';
import { StepDefineRule, aggregatableFields } from '.';
+import type { StepDefineRuleProps } from '.';
import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline';
import { TestProviders } from '../../../../common/mock';
@@ -24,6 +25,7 @@ import {
createIndexPatternField,
getSelectToggleButtonForName,
} from '../../../rule_creation/components/required_fields/required_fields.test';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
// Mocks integrations
jest.mock('../../../fleet_integrations/api');
@@ -35,6 +37,23 @@ jest.mock('../../../../common/components/query_bar', () => {
};
});
+jest.mock('../../../rule_creation/components/pick_timeline', () => ({
+ PickTimeline: 'pick-timeline',
+}));
+
+jest.mock('../ai_assistant', () => {
+ return {
+ AiAssistant: jest.fn(() => {
+ return ;
+ }),
+ };
+});
+
+jest.mock('../../../../common/hooks/use_experimental_features', () => ({
+ useIsExperimentalFeatureEnabled: jest.fn(),
+}));
+
+const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
const mockRedirectLegacyUrl = jest.fn();
const mockGetLegacyUrlConflict = jest.fn();
jest.mock('../../../../common/lib/kibana', () => {
@@ -610,6 +629,45 @@ describe('StepDefineRule', () => {
);
});
});
+
+ describe('AI assistant', () => {
+ beforeEach(() => {
+ useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
+ });
+ it('renders assistant when query is not valid', () => {
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(screen.getByTestId('ai-assistant')).toBeInTheDocument();
+ });
+
+ it('does not render assistant when feature flag is disabled', () => {
+ useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
+
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(screen.queryByTestId('ai-assistant')).toBe(null);
+ });
+
+ it('does not render assistant when query is valid', () => {
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(screen.queryByTestId('ai-assistant')).toBe(null);
+ });
+
+ it('does not render assistant for ML rule type', () => {
+ render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(screen.queryByTestId('ai-assistant')).toBe(null);
+ });
+ });
});
interface TestFormProps {
@@ -617,6 +675,7 @@ interface TestFormProps {
ruleType?: RuleType;
indexPattern?: DataViewBase;
onSubmit?: FormSubmitHandler;
+ formProps?: Partial;
}
function TestForm({
@@ -624,6 +683,7 @@ function TestForm({
ruleType = stepDefineDefaultValue.ruleType,
indexPattern = { fields: [], title: '' },
onSubmit,
+ formProps,
}: TestFormProps): JSX.Element {
const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions);
const { form } = useForm({
@@ -658,6 +718,7 @@ function TestForm({
queryBarSavedId=""
thresholdFields={[]}
enableThresholdSuppression={false}
+ {...formProps}
/>