diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/tools/tool_result.ts b/x-pack/platform/packages/shared/onechat/onechat-common/tools/tool_result.ts index 718184170c638..65a6200d83c05 100644 --- a/x-pack/platform/packages/shared/onechat/onechat-common/tools/tool_result.ts +++ b/x-pack/platform/packages/shared/onechat/onechat-common/tools/tool_result.ts @@ -115,6 +115,10 @@ export const isErrorResult = (result: ToolResult): result is ErrorResult => { return result.type === ToolResultType.error; }; +export const isDashboardResult = (result: ToolResult): result is DashboardResult => { + return result.type === ToolResultType.dashboard; +}; + export interface VisualizationElementAttributes { toolResultId?: string; chartType?: ChartType; @@ -127,3 +131,14 @@ export const visualizationElement = { chartType: 'chart-type', }, }; + +export interface DashboardElementAttributes { + toolResultId?: string; +} + +export const dashboardElement = { + tagName: 'dashboard', + attributes: { + toolResultId: 'tool-result-id', + }, +}; diff --git a/x-pack/platform/plugins/shared/dashboard_agent/server/register_agent.ts b/x-pack/platform/plugins/shared/dashboard_agent/server/register_agent.ts index bb29a8a9ee1b2..61537b7a85164 100644 --- a/x-pack/platform/plugins/shared/dashboard_agent/server/register_agent.ts +++ b/x-pack/platform/plugins/shared/dashboard_agent/server/register_agent.ts @@ -6,6 +6,7 @@ */ import { ToolResultType, platformCoreTools } from '@kbn/onechat-common'; +import { dashboardElement } from '@kbn/onechat-common/tools/tool_result'; import type { OnechatPluginSetup } from '@kbn/onechat-plugin/server'; import { dashboardTools } from '../common'; @@ -108,17 +109,22 @@ General Guidelines: function renderDashboardResultPrompt() { const { dashboard } = ToolResultType; + const { tagName, attributes } = dashboardElement; - return `#### Handling Dashboard Results - When a tool call returns a result of type "${dashboard}", you should inform the user that a dashboard has been created and provide relevant information about it. + return `#### Rendering Dashboards + When a tool call returns a result of type "${dashboard}", you should render the dashboard in the UI by emitting a custom XML element: + + <${tagName} ${attributes.toolResultId}="TOOL_RESULT_ID_HERE" /> **Rules** - * When you receive a tool result with \`"type": "${dashboard}"\`, extract the \`id\`, \`title\`, and other relevant data from the result. - * Provide a clickable link if a URL is available in \`content.url\`. + * The \`<${tagName}>\` element must only be used to render tool results of type \`${dashboard}\`. + * You must copy the \`tool_result_id\` from the tool's response into the \`${attributes.toolResultId}\` element attribute verbatim. + * Do not invent, alter, or guess \`tool_result_id\`. You must use the exact id provided in the tool response. + * You must not include any other attributes or content within the \`<${tagName}>\` element. - **Example for Dashboard:** + **Example Usage:** - Tool response: + Tool response includes: { "tool_result_id": "abc123", "type": "${dashboard}", @@ -133,6 +139,10 @@ function renderDashboardResultPrompt() { } } - Your response to the user should include: - Dashboard "My Dashboard" created successfully. You can view it at: [/app/dashboards#/view/dashboard-123](/app/dashboards#/view/dashboard-123)`; + To render this dashboard your reply should include: + <${tagName} ${attributes.toolResultId}="abc123" /> + + You may also add a brief message about the dashboard creation, for example: + "I've created a dashboard for you:" + <${tagName} ${attributes.toolResultId}="abc123" />`; } diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx index 3124506195114..7fb1d65ef4b46 100644 --- a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/chat_message_text.tsx @@ -22,14 +22,16 @@ import { } from '@elastic/eui'; import { type PluggableList } from 'unified'; import type { ConversationRoundStep } from '@kbn/onechat-common'; -import { visualizationElement } from '@kbn/onechat-common/tools/tool_result'; +import { visualizationElement, dashboardElement } from '@kbn/onechat-common/tools/tool_result'; import { useOnechatServices } from '../../../../hooks/use_onechat_service'; import { Cursor, esqlLanguagePlugin, createVisualizationRenderer, + createDashboardRenderer, loadingCursorPlugin, visualizationTagParser, + dashboardTagParser, } from './markdown_plugins'; import { useStepsFromPrevRounds } from '../../../../hooks/use_conversation'; @@ -125,6 +127,10 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props stepsFromCurrentRound, stepsFromPrevRounds, }), + [dashboardElement.tagName]: createDashboardRenderer({ + stepsFromCurrentRound, + stepsFromPrevRounds, + }), }; return { @@ -132,6 +138,7 @@ export function ChatMessageText({ content, steps: stepsFromCurrentRound }: Props loadingCursorPlugin, esqlLanguagePlugin, visualizationTagParser, + dashboardTagParser, ...parsingPlugins, ], processingPluginList: processingPlugins, diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins.tsx index 2ee0b9e810b05..52ce379afe8d4 100644 --- a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins.tsx +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins.tsx @@ -10,14 +10,26 @@ import { css } from '@emotion/css'; import React from 'react'; import { visualizationElement, + dashboardElement, type VisualizationElementAttributes, + type DashboardElementAttributes, type TabularDataResult, type VisualizationResult, + type DashboardResult, ToolResultType, } from '@kbn/onechat-common/tools/tool_result'; import type { ConversationRoundStep } from '@kbn/onechat-common'; import classNames from 'classnames'; -import { EuiCode, EuiText, useEuiTheme } from '@elastic/eui'; +import { + EuiCode, + EuiText, + useEuiTheme, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiIcon, +} from '@elastic/eui'; import type { OnechatStartDependencies } from '../../../../../types'; import { VisualizeESQL } from '../../../tools/esql/visualize_esql'; @@ -29,92 +41,110 @@ type MutableNode = Node & { chartType?: string; }; -export const visualizationTagParser = () => { - const extractAttribute = (value: string, attr: string) => { - const regex = new RegExp(`${attr}="([^"]*)"`, 'i'); - return value.match(regex)?.[1]; +const createTagParser = >(config: { + tagName: string; + getAttributes: ( + value: string, + extractAttr: (value: string, attr: string) => string | undefined + ) => T; + assignAttributes: (node: MutableNode, attributes: T) => void; + createNode: (attributes: T, position: MutableNode['position']) => MutableNode; +}) => { + return () => { + const extractAttribute = (value: string, attr: string) => { + const regex = new RegExp(`${attr}="([^"]*)"`, 'i'); + return value.match(regex)?.[1]; + }; + + const tagRegex = new RegExp(`<${config.tagName}\\b[^>]*\\/?>`, 'gi'); + + const visitParent = (parent: Parent) => { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index] as MutableNode; + + if ('children' in child) { + visitParent(child as Parent); + } + + if (child.type !== 'html') { + continue; // terminate iteration if not html node + } + + const rawValue = child.value; + if (!rawValue) { + continue; // terminate iteration if no value attribute + } + + const trimmedValue = rawValue.trim(); + if (!trimmedValue.toLowerCase().startsWith(`<${config.tagName}`)) { + continue; // terminate iteration if not starting with tag + } + + const matches = Array.from(trimmedValue.matchAll(tagRegex)); + if (matches.length === 0) { + continue; // terminate iteration if no matches found + } + + const parsedAttributes = matches.map((match) => + config.getAttributes(match[0], extractAttribute) + ); + const leftoverContent = trimmedValue.replace(tagRegex, '').trim(); + + config.assignAttributes(child, parsedAttributes[0]); + + if (parsedAttributes.length === 1 || leftoverContent.length > 0) { + continue; + } + + const additionalNodes = parsedAttributes + .slice(1) + .map((attributes) => config.createNode(attributes, child.position)); + + const siblings = parent.children as Node[]; + siblings.splice(index + 1, 0, ...additionalNodes); + index += additionalNodes.length; + continue; + } + }; + + return (tree: Node) => { + if ('children' in tree) { + visitParent(tree as Parent); + } + }; }; +}; - const getVisualizationAttributes = (value: string) => ({ - toolResultId: extractAttribute(value, visualizationElement.attributes.toolResultId), - chartType: extractAttribute(value, visualizationElement.attributes.chartType), - }); +const findToolResult = ( + steps: ConversationRoundStep[], + toolResultId: string, + resultType: ToolResultType +): T | undefined => { + return steps + .filter((s) => s.type === 'tool_call') + .flatMap((s) => (s.type === 'tool_call' && s.results) || []) + .find((r) => r.type === resultType && r.tool_result_id === toolResultId) as T | undefined; +}; - const assignVisualizationAttributes = ( - node: MutableNode, - attributes: ReturnType - ) => { +export const visualizationTagParser = createTagParser({ + tagName: visualizationElement.tagName, + getAttributes: (value, extractAttr) => ({ + toolResultId: extractAttr(value, visualizationElement.attributes.toolResultId), + chartType: extractAttr(value, visualizationElement.attributes.chartType), + }), + assignAttributes: (node, attributes) => { node.type = visualizationElement.tagName; node.toolResultId = attributes.toolResultId; node.chartType = attributes.chartType; delete node.value; - }; - - const visualizationTagRegex = new RegExp(`<${visualizationElement.tagName}\\b[^>]*\\/?>`, 'gi'); - - const createVisualizationNode = ( - attributes: ReturnType, - position: MutableNode['position'] - ): MutableNode => ({ + }, + createNode: (attributes, position) => ({ type: visualizationElement.tagName, toolResultId: attributes.toolResultId, chartType: attributes.chartType, position, - }); - - const visitParent = (parent: Parent) => { - for (let index = 0; index < parent.children.length; index++) { - const child = parent.children[index] as MutableNode; - - if ('children' in child) { - visitParent(child as Parent); - } - - if (child.type !== 'html') { - continue; // terminate iteration if not html node - } - - const rawValue = child.value; - if (!rawValue) { - continue; // terminate iteration if no value attribute - } - - const trimmedValue = rawValue.trim(); - if (!trimmedValue.toLowerCase().startsWith(`<${visualizationElement.tagName}`)) { - continue; // terminate iteration if not starting with visualization tag - } - - const matches = Array.from(trimmedValue.matchAll(visualizationTagRegex)); - if (matches.length === 0) { - continue; // terminate iteration if no matches found - } - - const visualizationAttributes = matches.map((match) => getVisualizationAttributes(match[0])); - const leftoverContent = trimmedValue.replace(visualizationTagRegex, '').trim(); - - assignVisualizationAttributes(child, visualizationAttributes[0]); - - if (visualizationAttributes.length === 1 || leftoverContent.length > 0) { - continue; - } - - const additionalNodes = visualizationAttributes - .slice(1) - .map((attributes) => createVisualizationNode(attributes, child.position)); - - const siblings = parent.children as Node[]; - siblings.splice(index + 1, 0, ...additionalNodes); - index += additionalNodes.length; - continue; - } - }; - - return (tree: Node) => { - if ('children' in tree) { - visitParent(tree as Parent); - } - }; -}; + }), +}); export function createVisualizationRenderer({ startDependencies, @@ -143,21 +173,16 @@ export function createVisualizationRenderer({ ); // First, look for tabular data results (from execute_esql) - let toolResult: TabularDataResult | VisualizationResult | undefined = steps - .filter((s) => s.type === 'tool_call') - .flatMap((s) => (s.type === 'tool_call' && s.results) || []) - .find((r) => r.type === ToolResultType.tabularData && r.tool_result_id === toolResultId) as - | TabularDataResult - | undefined; + let toolResult: TabularDataResult | VisualizationResult | undefined = + findToolResult(steps, toolResultId, ToolResultType.tabularData); // If not found, look for visualization results (from create_visualization) if (!toolResult) { - toolResult = steps - .filter((s) => s.type === 'tool_call') - .flatMap((s) => (s.type === 'tool_call' && s.results) || []) - .find( - (r) => r.type === ToolResultType.visualization && r.tool_result_id === toolResultId - ) as VisualizationResult | undefined; + toolResult = findToolResult( + steps, + toolResultId, + ToolResultType.visualization + ); } if (!toolResult) { @@ -196,6 +221,111 @@ export function createVisualizationRenderer({ }; } +export const dashboardTagParser = createTagParser({ + tagName: dashboardElement.tagName, + getAttributes: (value, extractAttr) => ({ + toolResultId: extractAttr(value, dashboardElement.attributes.toolResultId), + }), + assignAttributes: (node, attributes) => { + node.type = dashboardElement.tagName; + node.toolResultId = attributes.toolResultId; + delete node.value; + }, + createNode: (attributes, position) => ({ + type: dashboardElement.tagName, + toolResultId: attributes.toolResultId, + position, + }), +}); + +const DashboardCard: React.FC<{ + title: string; + url?: string; +}> = ({ title, url }) => { + const { euiTheme } = useEuiTheme(); + + const iconContainerStyles = css` + display: flex; + align-items: center; + justify-content: center; + width: ${euiTheme.size.xxl}; + height: ${euiTheme.size.xxl}; + border-radius: ${euiTheme.border.radius.medium}; + background-color: ${euiTheme.colors.primary}; + `; + + const panelStyles = css` + border: ${euiTheme.border.width.thin} solid ${euiTheme.colors.primary}; + `; + + const cardContent = ( + + + +
+ +
+
+ + + {title} + + + Dashboard (Temporary) + + +
+
+ ); + + if (url) { + return ( + + {cardContent} + + ); + } + + return cardContent; +}; + +export function createDashboardRenderer({ + stepsFromCurrentRound, + stepsFromPrevRounds, +}: { + stepsFromCurrentRound: ConversationRoundStep[]; + stepsFromPrevRounds: ConversationRoundStep[]; +}) { + return (props: DashboardElementAttributes) => { + const { toolResultId } = props; + + if (!toolResultId) { + return Dashboard missing {dashboardElement.attributes.toolResultId}.; + } + + const steps = [...stepsFromPrevRounds, ...stepsFromCurrentRound]; + const toolResult = findToolResult( + steps, + toolResultId, + ToolResultType.dashboard + ); + + if (!toolResult) { + const ToolResultAttribute = ( + + {dashboardElement.attributes.toolResultId}={toolResultId} + + ); + return Unable to find dashboard for {ToolResultAttribute}.; + } + + const { title, content } = toolResult.data; + const dashboardUrl = content?.url as string | undefined; + + return ; + }; +} + const CURSOR = ` ᠎  `; export const loadingCursorPlugin = () => { const visitor = (node: Node, parent?: Parent) => {