Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,51 @@
* 2.0.
*/

import type { ReactNode } from 'react';
import type { IconType } from '@elastic/eui';
import type { UnknownAttachment, AttachmentVersion } from '@kbn/agent-builder-common/attachments';

export enum ActionButtonType {
PRIMARY = 'primary',
SECONDARY = 'secondary',
OVERFLOW = 'overflow',
}
/**
* Props passed to custom attachment content renderers.
*/
export interface AttachmentRenderProps<TAttachment extends UnknownAttachment = UnknownAttachment> {
/** The attachment to render */
attachment: TAttachment;
/** Whether the attachment is being rendered in a sidebar context */
isSidebar: boolean;
}

/**
* Parameters passed when requesting action buttons for an inline-rendered attachment.
*/
export interface GetActionButtonsParams<TAttachment extends UnknownAttachment = UnknownAttachment> {
/** The attachment for which to provide action buttons */
attachment: TAttachment;
/** Whether the attachment is being rendered in a sidebar context */
isSidebar: boolean;
/** Function to update the attachment's origin reference */
updateOrigin: (originId: string) => Promise<void>;
}

/**
* Action button definition for inline-rendered attachments.
*/
export interface ActionButton {
/** Button label text */
label: string;
/** Optional icon to display in the button (EUI icon name or custom React element) */
icon?: IconType;
/** Whether this is the primary action button */
type: ActionButtonType;
/** Handler function called when the button is clicked */
handler: () => void | Promise<void>;
}

/**
* UI definition for rendering attachments of a specific type.
*/
Expand All @@ -25,6 +67,17 @@ export interface AttachmentUIDefinition<TAttachment extends UnknownAttachment =
* When provided, pills will invoke this instead of the default behavior.
*/
onClick?: (args: { attachment: TAttachment; version?: AttachmentVersion }) => void;
/**
* Optional custom content renderer for inline attachment display.
* When provided, attachments can be rendered inline in the conversation
* using the <render_attachment> tag.
*/
renderContent?: (props: AttachmentRenderProps<TAttachment>) => ReactNode;
/**
* Optional function to provide action buttons for inline-rendered attachments.
* Buttons will appear alongside or below the rendered content.
*/
getActionButtons?: (params: GetActionButtonsParams<TAttachment>) => ActionButton[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@
* 2.0.
*/

export type { AttachmentUIDefinition, AttachmentServiceStartContract } from './contract';
export type {
AttachmentUIDefinition,
AttachmentServiceStartContract,
AttachmentRenderProps,
GetActionButtonsParams,
ActionButton,
} from './contract';
export { ActionButtonType } from './contract';
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,16 @@ export const dashboardElement = {
toolResultId: 'tool-result-id',
},
};

export interface RenderAttachmentElementAttributes {
attachmentId?: string;
version?: number | string;
}

export const renderAttachmentElement = {
tagName: 'render_attachment',
attributes: {
attachmentId: 'id',
version: 'version',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const ConversationRounds: React.FC<ConversationRoundsProps> = ({
scrollContainerHeight={scrollContainerHeight}
isCurrentRound={isCurrentRound}
rawRound={round}
conversationId={conversation?.id}
conversationAttachments={conversation?.attachments}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface RoundLayoutProps {
scrollContainerHeight: number;
rawRound: ConversationRound;
conversationAttachments?: VersionedAttachment[];
conversationId?: string;
}

const labels = {
Expand All @@ -40,6 +41,7 @@ export const RoundLayout: React.FC<RoundLayoutProps> = ({
scrollContainerHeight,
rawRound,
conversationAttachments,
conversationId,
}) => {
const [roundContainerMinHeight, setRoundContainerMinHeight] = useState(0);
const [hasBeenLoading, setHasBeenLoading] = useState(false);
Expand Down Expand Up @@ -153,6 +155,9 @@ export const RoundLayout: React.FC<RoundLayoutProps> = ({
steps={steps}
isLoading={isLoadingCurrentRound}
isLastRound={isCurrentRound}
conversationAttachments={conversationAttachments}
attachmentRefs={input.attachment_refs}
conversationId={conversationId}
/>
</EuiFlexItem>
<EuiSpacer />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 type { UnknownAttachment } from '@kbn/agent-builder-common/attachments';
import { EuiButton } from '@elastic/eui';
import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
import type { AttachmentsService } from '../../../../../services/attachments/attachements_service';
import { useKibana } from '../../../../hooks/use_kibana';

interface AttachmentWithActionsProps {
attachment: UnknownAttachment;
attachmentsService: AttachmentsService;
isSidebar: boolean;
conversationId: string;
}

/**
* Component that renders an attachment with its action buttons.
*/
export const AttachmentWithActions: React.FC<AttachmentWithActionsProps> = ({
attachment,
attachmentsService,
isSidebar,
conversationId,
}) => {
const {
services: { settings },
} = useKibana();
const isExperimentalFeaturesEnabled = settings?.client.get<boolean>(
AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID,
false
);

if (isExperimentalFeaturesEnabled === false) {
return null;
}
Comment on lines +34 to +41
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc. @joemcelroy whilst I've yet to implement the actual UI (future PR), I'd like to keep this behind the experimental feature flag just to ensure if the agent does include an <attachment_renderer /> XML tag that it doesn't show anything in the UI as we iteratively work towards including the final design - thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep this is good


const uiDefinition = attachmentsService.getAttachmentUiDefinition(attachment.type);

if (!uiDefinition) {
return null;
}

const actionButtons = uiDefinition.getActionButtons?.({
attachment,
isSidebar,
updateOrigin: async (originId: string) => {
// TODO: Implement updateOrigin
// attachmentsService.updateOrigin(conversationId, attachment.id, originId);
},
});

return (
<>
{actionButtons?.map((button) => (
<EuiButton key={button.label} onClick={button.handler}>
{button.label}
</EuiButton>
))}
{uiDefinition?.renderContent?.({ attachment, isSidebar })}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,46 @@ import {
} from '@elastic/eui';
import { type PluggableList } from 'unified';
import type { ConversationRoundStep } from '@kbn/agent-builder-common';
import { visualizationElement } from '@kbn/agent-builder-common/tools/custom_rendering';
import type {
VersionedAttachment,
AttachmentVersionRef,
} from '@kbn/agent-builder-common/attachments';
import {
visualizationElement,
renderAttachmentElement,
} from '@kbn/agent-builder-common/tools/custom_rendering';
import { useAgentBuilderServices } from '../../../../hooks/use_agent_builder_service';
import {
Cursor,
esqlLanguagePlugin,
createVisualizationRenderer,
loadingCursorPlugin,
visualizationTagParser,
renderAttachmentTagParser,
createRenderAttachmentRenderer,
} from './markdown_plugins';
import { useStepsFromPrevRounds } from '../../../../hooks/use_conversation';
import { useConversationContext } from '../../../../context/conversation/conversation_context';

interface Props {
content: string;
steps: ConversationRoundStep[];
conversationAttachments?: VersionedAttachment[];
attachmentRefs?: AttachmentVersionRef[];
conversationId?: string;
}

/**
* Component handling markdown support to the assistant's responses.
* Also handles "loading" state by appending the blinking cursor.
*/
export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props) {
export function ChatMessageText({
content,
steps: stepsFromCurrentRound,
conversationAttachments,
attachmentRefs,
conversationId,
}: Props) {
const { euiTheme } = useEuiTheme();

const containerClassName = css`
Expand All @@ -58,8 +77,9 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props
}
`;

const { startDependencies } = useAgentBuilderServices();
const { attachmentsService, startDependencies } = useAgentBuilderServices();
const stepsFromPrevRounds = useStepsFromPrevRounds();
const { isEmbeddedContext: isSidebar } = useConversationContext();

const { parsingPluginList, processingPluginList } = useMemo(() => {
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
Expand Down Expand Up @@ -125,18 +145,35 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props
stepsFromCurrentRound,
stepsFromPrevRounds,
}),
[renderAttachmentElement.tagName]: createRenderAttachmentRenderer({
conversationAttachments,
attachmentRefs,
conversationId,
isSidebar,
attachmentsService,
}),
};

return {
parsingPluginList: [
loadingCursorPlugin,
esqlLanguagePlugin,
visualizationTagParser,
renderAttachmentTagParser,
...parsingPlugins,
],
processingPluginList: processingPlugins,
};
}, [startDependencies, stepsFromCurrentRound, stepsFromPrevRounds]);
}, [
startDependencies,
stepsFromCurrentRound,
stepsFromPrevRounds,
conversationAttachments,
attachmentRefs,
conversationId,
isSidebar,
attachmentsService,
]);

return (
<EuiText size="m" className={containerClassName}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@

export { findToolResult, type MutableNode } from './utils';
export { visualizationTagParser, createVisualizationRenderer } from './visualization_plugin';
export {
renderAttachmentTagParser,
createRenderAttachmentRenderer,
} from './render_attachment_plugin';
export { loadingCursorPlugin, Cursor } from './cursor_plugin';
export { esqlLanguagePlugin } from './esql_plugin';
Loading
Loading