diff --git a/x-pack/platform/packages/shared/onechat/onechat-common/chat/conversation.ts b/x-pack/platform/packages/shared/onechat/onechat-common/chat/conversation.ts index 29f233fda4120..6d63d6d4e3221 100644 --- a/x-pack/platform/packages/shared/onechat/onechat-common/chat/conversation.ts +++ b/x-pack/platform/packages/shared/onechat/onechat-common/chat/conversation.ts @@ -6,7 +6,12 @@ */ import type { StructuredToolIdentifier } from '../tools/tools'; -import type { SerializedAgentIdentifier } from '../agents'; +import { + OneChatDefaultAgentId, + toSerializedAgentIdentifier, + type SerializedAgentIdentifier, + OneChatDefaultAgentProviderId, +} from '../agents'; import type { UserIdAndName } from '../base/users'; /** @@ -64,6 +69,13 @@ export type ToolCallStep = ConversationRoundStepMixin< ToolCallWithResult >; +export const createToolCallStep = (toolCallWithResult: ToolCallWithResult): ToolCallStep => { + return { + type: ConversationRoundStepType.toolCall, + ...toolCallWithResult, + }; +}; + export const isToolCallStep = (step: ConversationRoundStep): step is ToolCallStep => { return step.type === ConversationRoundStepType.toolCall; }; @@ -93,3 +105,19 @@ export interface Conversation { updatedAt: string; rounds: ConversationRound[]; } + +export const createEmptyConversation = (): Conversation => { + const now = new Date().toISOString(); + return { + id: 'new', + agentId: toSerializedAgentIdentifier({ + agentId: OneChatDefaultAgentId, + providerId: OneChatDefaultAgentProviderId, + }), + user: { id: '', username: '' }, + title: '', + createdAt: now, + updatedAt: now, + rounds: [], + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/README.md b/x-pack/platform/plugins/shared/onechat/README.md index 9206a2a3f5a8f..d63863d1d136b 100644 --- a/x-pack/platform/plugins/shared/onechat/README.md +++ b/x-pack/platform/plugins/shared/onechat/README.md @@ -256,3 +256,11 @@ Configure Claude Desktop by adding this to its configuration: } } ``` + +## Chat UI +To enable the Chat UI located at `/app/chat/`, add the following to your Kibana config: + +```yaml +uiSettings.overrides: + onechat:ui:enabled: true +``` diff --git a/x-pack/platform/plugins/shared/onechat/common/conversations.ts b/x-pack/platform/plugins/shared/onechat/common/conversations.ts index 0e52447445e81..7d045350d31a3 100644 --- a/x-pack/platform/plugins/shared/onechat/common/conversations.ts +++ b/x-pack/platform/plugins/shared/onechat/common/conversations.ts @@ -20,3 +20,7 @@ export type ConversationUpdateRequest = Pick & export interface ConversationListOptions { agentId?: AgentIdentifier; } + +export interface ConversationGetOptions { + conversationId: string; +} diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation.tsx new file mode 100644 index 0000000000000..3defcea1a438a --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation.tsx @@ -0,0 +1,86 @@ +/* + * 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, useRef, useEffect } from 'react'; +import { css } from '@emotion/css'; +import { EuiFlexItem, EuiPanel, useEuiTheme, euiScrollBarStyles } from '@elastic/eui'; +import { useChat } from '../../hooks/use_chat'; +import { useConversation } from '../../hooks/use_conversation'; +import { useStickToBottom } from '../../hooks/use_stick_to_bottom'; +import { ConversationInputForm } from './conversation_input_form'; +import { ConversationRounds } from './conversation_rounds/conversation_rounds'; +import { NewConversationPrompt } from './new_conversation_prompt'; + +const fullHeightClassName = css` + height: 100%; +`; + +const conversationPanelClass = css` + min-height: 100%; + max-width: 850px; + margin-left: auto; + margin-right: auto; +`; + +const scrollContainerClassName = (scrollBarStyles: string) => css` + overflow-y: auto; + ${scrollBarStyles} +`; + +interface ConversationProps { + agentId: string; + conversationId: string | undefined; +} + +export const Conversation: React.FC = ({ agentId, conversationId }) => { + const { conversation } = useConversation({ conversationId }); + const { sendMessage } = useChat({ + conversationId, + agentId, + }); + + const theme = useEuiTheme(); + const scrollBarStyles = euiScrollBarStyles(theme); + + const scrollContainerRef = useRef(null); + + const { setStickToBottom } = useStickToBottom({ + defaultState: true, + scrollContainer: scrollContainerRef.current, + }); + + useEffect(() => { + setStickToBottom(true); + }, [conversationId, setStickToBottom]); + + const onSubmit = useCallback( + (message: string) => { + setStickToBottom(true); + sendMessage(message); + }, + [sendMessage, setStickToBottom] + ); + + if (!conversationId && (!conversation || conversation.rounds.length === 0)) { + return ; + } + + return ( + <> + +
+ + + +
+
+ + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_header.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_header.tsx new file mode 100644 index 0000000000000..2461d3ec764aa --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_header.tsx @@ -0,0 +1,64 @@ +/* + * 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 { css } from '@emotion/css'; +import { + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSkeletonTitle, + useEuiTheme, + useEuiFontSize, +} from '@elastic/eui'; +import { useConversation } from '../../hooks/use_conversation'; +import { chatCommonLabels } from './i18n'; + +interface ConversationHeaderProps { + conversationId: string | undefined; +} + +export const ConversationHeader: React.FC = ({ conversationId }) => { + const { conversation, isLoading: isConvLoading } = useConversation({ conversationId }); + + const { euiTheme } = useEuiTheme(); + + const containerClass = css` + padding: ${euiTheme.size.s} ${euiTheme.size.m}; + border-bottom: solid ${euiTheme.border.width.thin} ${euiTheme.border.color}; + `; + + const conversationTitleClass = css` + font-weight: ${euiTheme.font.weight.semiBold}; + font-size: ${useEuiFontSize('m').fontSize}; + `; + + return ( + + + + + + +

+ {conversation?.title || chatCommonLabels.chat.conversations.newConversationLabel} +

+
+
+
+
+
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_input_form.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_input_form.tsx new file mode 100644 index 0000000000000..14d600ce34604 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_input_form.tsx @@ -0,0 +1,98 @@ +/* + * 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, useState, KeyboardEvent } from 'react'; +import { css } from '@emotion/css'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTextArea, + keys, + useEuiTheme, +} from '@elastic/eui'; +import { chatCommonLabels } from './i18n'; + +interface ConversationInputFormProps { + disabled: boolean; + loading: boolean; + onSubmit: (message: string) => void; +} + +export const ConversationInputForm: React.FC = ({ + disabled, + loading, + onSubmit, +}) => { + const [message, setMessage] = useState(''); + const { euiTheme } = useEuiTheme(); + + const handleSubmit = useCallback(() => { + if (loading || !message.trim()) { + return; + } + + onSubmit(message); + setMessage(''); + }, [message, loading, onSubmit]); + + const handleChange = useCallback((event: React.ChangeEvent) => { + setMessage(event.currentTarget.value); + }, []); + + const handleTextAreaKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!event.shiftKey && event.key === keys.ENTER) { + event.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const topContainerClass = css` + padding-bottom: ${euiTheme.size.m}; + `; + + const inputFlexItemClass = css` + max-width: 900px; + `; + + return ( + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_panel/conversation_panel.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_panel/conversation_panel.tsx new file mode 100644 index 0000000000000..78e8ab3e28feb --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_panel/conversation_panel.tsx @@ -0,0 +1,120 @@ +/* + * 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 { css } from '@emotion/css'; +import { + EuiText, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + useEuiTheme, + EuiSpacer, + EuiIcon, + EuiHorizontalRule, + euiScrollBarStyles, +} from '@elastic/eui'; +import { chatCommonLabels } from '../i18n'; + +interface ConversationPanelProps { + onNewConversationSelect?: () => void; +} + +export const ConversationPanel: React.FC = ({ + onNewConversationSelect, +}) => { + const theme = useEuiTheme(); + const scrollBarStyles = euiScrollBarStyles(theme); + + const containerClassName = css` + height: 100%; + width: 100%; + `; + + const titleClassName = css` + text-transform: capitalize; + font-weight: ${theme.euiTheme.font.weight.bold}; + `; + + const pageSectionContentClassName = css` + width: 100%; + display: flex; + flex-grow: 1; + height: 100%; + max-block-size: calc(100vh - var(--kbnAppHeadersOffset, var(--euiFixedHeadersOffset, 0))); + background-color: ${theme.euiTheme.colors.backgroundBasePlain}; + padding: ${theme.euiTheme.size.base} 0; + `; + + const sectionBlockPaddingCLassName = css` + padding: 0 ${theme.euiTheme.size.base}; + `; + + const createButtonRuleClassName = css` + margin-bottom: ${theme.euiTheme.size.base}; + `; + + const scrollContainerClassName = css` + overflow-y: auto; + padding: 0 ${theme.euiTheme.size.base}; + ${scrollBarStyles} + `; + + return ( + + + + + + + + + + {chatCommonLabels.chat.conversations.conversationsListTitle} + + + + + + + + {/* Todo: Add conversation groups */} + + + + + + + { + onNewConversationSelect?.(); + }} + > + {chatCommonLabels.chat.conversations.newConversationLabel} + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/chat_message_text.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/chat_message_text.tsx new file mode 100644 index 0000000000000..7b5aae82a993d --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/chat_message_text.tsx @@ -0,0 +1,190 @@ +/* + * 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 { css } from '@emotion/css'; +import classNames from 'classnames'; +import type { Code, InlineCode, Parent, Text } from 'mdast'; +import React, { useMemo } from 'react'; +import type { Node } from 'unist'; +import { + EuiCodeBlock, + EuiTable, + EuiTableRow, + EuiTableRowCell, + EuiTableHeaderCell, + EuiMarkdownFormat, + EuiSpacer, + EuiText, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; + +interface Props { + content: string; +} + +const cursorCss = css` + @keyframes blink { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + + animation: blink 1s infinite; + width: 10px; + height: 16px; + vertical-align: middle; + display: inline-block; + background: rgba(0, 0, 0, 0.25); +`; + +const Cursor = () => ; + +const CURSOR = ` ᠎  `; + +const loadingCursorPlugin = () => { + const visitor = (node: Node, parent?: Parent) => { + if ('children' in node) { + const nodeAsParent = node as Parent; + nodeAsParent.children.forEach((child) => { + visitor(child, nodeAsParent); + }); + } + + if (node.type !== 'text' && node.type !== 'inlineCode' && node.type !== 'code') { + return; + } + + const textNode = node as Text | InlineCode | Code; + + const indexOfCursor = textNode.value.indexOf(CURSOR); + if (indexOfCursor === -1) { + return; + } + + textNode.value = textNode.value.replace(CURSOR, ''); + + const indexOfNode = parent!.children.indexOf(textNode); + parent!.children.splice(indexOfNode + 1, 0, { + type: 'cursor' as Text['type'], + value: CURSOR, + }); + }; + + return (tree: Node) => { + visitor(tree); + }; +}; + +const esqlLanguagePlugin = () => { + const visitor = (node: Node, parent?: Parent) => { + if ('children' in node) { + const nodeAsParent = node as Parent; + nodeAsParent.children.forEach((child) => { + visitor(child, nodeAsParent); + }); + } + + if (node.type === 'code' && node.lang === 'esql') { + node.type = 'esql'; + } else if (node.type === 'code') { + // switch to type that allows us to control rendering + node.type = 'codeBlock'; + } + }; + + return (tree: Node) => { + visitor(tree); + }; +}; + +/** + * Component handling markdown support to the assistant's responses. + * Also handles "loading" state by appending the blinking cursor. + */ +export function ChatMessageText({ content }: Props) { + const containerClassName = css` + overflow-wrap: anywhere; + `; + + const { parsingPluginList, processingPluginList } = useMemo(() => { + const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); + const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); + + const { components } = processingPlugins[1][1]; + + processingPlugins[1][1].components = { + ...components, + cursor: Cursor, + codeBlock: (props) => { + return ( + <> + {props.value} + + + ); + }, + esql: (props) => { + return ( + <> + {props.value} + + + ); + }, + table: (props) => ( + <> + + + + ), + th: (props) => { + const { children, ...rest } = props; + return {children}; + }, + tr: (props) => , + td: (props) => { + const { children, ...rest } = props; + return ( + + {children} + + ); + }, + }; + + return { + parsingPluginList: [loadingCursorPlugin, esqlLanguagePlugin, ...parsingPlugins], + processingPluginList: processingPlugins, + }; + }, []); + + return ( + + + {content} + + + ); +} diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/conversation_rounds.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/conversation_rounds.tsx new file mode 100644 index 0000000000000..a1bdb23672376 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/conversation_rounds.tsx @@ -0,0 +1,24 @@ +/* + * 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 { ConversationRound } from '@kbn/onechat-common'; +import { Round } from './round'; + +interface ConversationRoundsProps { + conversationRounds: ConversationRound[]; +} + +export const ConversationRounds: React.FC = ({ conversationRounds }) => { + return ( + <> + {conversationRounds.map((round, index) => { + return ; + })} + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round.tsx new file mode 100644 index 0000000000000..e276436d62811 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round.tsx @@ -0,0 +1,59 @@ +/* + * 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 { css } from '@emotion/css'; +import { EuiPanel, EuiText, useEuiTheme, useEuiFontSize } from '@elastic/eui'; +import { ConversationRound } from '@kbn/onechat-common'; +import { RoundAnswer } from './round_answer'; + +interface RoundProps { + round: ConversationRound; +} + +export const Round: React.FC = ({ round }) => { + const { euiTheme } = useEuiTheme(); + + const { userInput } = round; + + const rootPanelClass = css` + margin-bottom: ${euiTheme.size.xl}; + `; + + const tabContentPanelClass = css` + padding: ${euiTheme.size.xxl}; + `; + + const userTextContainerClass = css` + padding: ${euiTheme.size.xxl} ${euiTheme.size.xxl} ${euiTheme.size.xl} ${euiTheme.size.xxl}; + `; + + const userMessageTextClass = css` + font-weight: ${euiTheme.font.weight.regular}; + font-size: calc(${useEuiFontSize('m').fontSize} + 4px); + `; + + return ( + +
+ + “{userInput.message}“ + +
+ +
+ +
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_answer.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_answer.tsx new file mode 100644 index 0000000000000..5c62a8d9bb75d --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversation_rounds/round_answer.tsx @@ -0,0 +1,111 @@ +/* + * 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 { ConversationRound, ConversationRoundStepType } from '@kbn/onechat-common'; +import { + EuiPanel, + EuiText, + EuiSpacer, + useEuiTheme, + EuiIcon, + EuiCodeBlock, + EuiAccordion, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { ChatMessageText } from './chat_message_text'; + +export interface RoundAnswerProps { + round: ConversationRound; +} + +export const RoundAnswer: React.FC = ({ round }) => { + const { euiTheme } = useEuiTheme(); + const { assistantResponse, steps } = round; + + const toolCallPanelClass = css` + margin-bottom: ${euiTheme.size.m}; + padding: ${euiTheme.size.m}; + background-color: ${euiTheme.colors.lightestShade}; + `; + + const stepHeaderClass = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + `; + + const codeBlockClass = css` + background-color: ${euiTheme.colors.emptyShade}; + border: 1px solid ${euiTheme.colors.lightShade}; + border-radius: ${euiTheme.border.radius.medium}; + `; + + return ( + <> + {steps?.map((step) => { + if (step.type === ConversationRoundStepType.toolCall) { + return ( +
+ +
+ + + Tool: {step.toolId.toolId} + +
+ + + Tool call args + + } + paddingSize="s" + > +
+ + {JSON.stringify(step.args, null, 2)} + +
+
+ + {step.result ? ( +
+ + {step.result} + +
+ ) : ( + + No result available + + )} +
+ +
+ ); + } + return null; + })} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversations_view.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversations_view.tsx new file mode 100644 index 0000000000000..3703701ca1ffc --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/conversations_view.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiFlexGroup, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import { OneChatDefaultAgentId } from '@kbn/onechat-common'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import React from 'react'; +import { useNavigation } from '../../hooks/use_navigation'; +import { appPaths } from '../../utils/app_paths'; +import { Conversation } from './conversation'; +import { ConversationHeader } from './conversation_header'; +import { ConversationPanel } from './conversation_panel/conversation_panel'; + +export const OnechatConversationsView: React.FC<{ conversationId?: string }> = ({ + conversationId, +}) => { + const { navigateToOnechatUrl } = useNavigation(); + + const { euiTheme } = useEuiTheme(); + + const pageSectionContentClassName = css` + width: 100%; + display: flex; + flex-grow: 1; + padding-top: 0; + padding-bottom: 0; + height: 100%; + max-block-size: calc(100vh - var(--kbnAppHeadersOffset, var(--euiFixedHeadersOffset, 0))); + background-color: ${euiTheme.colors.backgroundBasePlain}; + `; + + return ( + + + { + navigateToOnechatUrl(appPaths.chat.new); + }} + /> + + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/i18n.ts b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/i18n.ts new file mode 100644 index 0000000000000..66184bdec5d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/i18n.ts @@ -0,0 +1,43 @@ +/* + * 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 chatCommonLabels = { + chat: { + conversations: { + conversationsListTitle: i18n.translate( + 'xpack.onechat.chat.conversations.conversationListTitle', + { + defaultMessage: 'Conversations', + } + ), + newConversationLabel: i18n.translate( + 'xpack.onechat.chat.conversations.newConversationLabel', + { + defaultMessage: 'New conversation', + } + ), + }, + }, + + userInputBox: { + placeholder: i18n.translate('xpack.onechat.userInputBox.placeholder', { + defaultMessage: 'Ask anything', + }), + }, + assistant: { + defaultNameLabel: i18n.translate('xpack.onechat.assistant.defaultNameLabel', { + defaultMessage: 'Assistant', + }), + }, + assistantStatus: { + healthy: i18n.translate('xpack.onechat.chat.assistantStatus.healthy', { + defaultMessage: 'Healthy', + }), + }, +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/new_conversation_prompt.tsx b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/new_conversation_prompt.tsx new file mode 100644 index 0000000000000..67efa0c38eee7 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/components/conversations/new_conversation_prompt.tsx @@ -0,0 +1,120 @@ +/* + * 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, { useState, useCallback, useEffect, useRef } from 'react'; +import { css } from '@emotion/css'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiTextArea, + EuiButtonIcon, + useEuiTheme, + keys, +} from '@elastic/eui'; +import { chatCommonLabels } from './i18n'; + +interface NewConversationPromptProps { + onSubmit: (message: string) => void; +} + +export const NewConversationPrompt: React.FC = ({ onSubmit }) => { + const inputRef = useRef(null); + const [message, setMessage] = useState(''); + const { euiTheme } = useEuiTheme(); + + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 200); + }, [inputRef]); + + const containerClass = css` + width: 100%; + max-width: 600px; + `; + + const inputContainerClass = css` + padding-top: ${euiTheme.size.l}; + width: 100%; + `; + + const inputFlexItemClass = css` + max-width: 900px; + `; + + const handleSubmit = useCallback(() => { + if (!message.trim()) { + return; + } + + onSubmit(message); + setMessage(''); + }, [message, onSubmit]); + + const handleChange = useCallback((event: React.ChangeEvent) => { + setMessage(event.currentTarget.value); + }, []); + + const handleTextAreaKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!event.shiftKey && event.key === keys.ENTER) { + event.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_chat.ts b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_chat.ts new file mode 100644 index 0000000000000..f2a46f4e3ce65 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_chat.ts @@ -0,0 +1,115 @@ +/* + * 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'; +import { + OnechatError, + OnechatErrorCode, + isConversationCreatedEvent, + isConversationUpdatedEvent, + isMessageChunkEvent, + isMessageCompleteEvent, + isOnechatError, + isToolCallEvent, + isToolResultEvent, +} from '@kbn/onechat-common'; +import { createToolCallStep } from '@kbn/onechat-common/chat/conversation'; +import { useCallback, useState } from 'react'; +import { useConversation } from './use_conversation'; +import { useKibana } from './use_kibana'; +import { useOnechatServices } from './use_onechat_service'; + +export type ChatStatus = 'ready' | 'loading' | 'error'; + +interface UseChatProps { + conversationId: string | undefined; + agentId: string; + connectorId?: string; + onError?: (error: OnechatError) => void; +} + +export const useChat = ({ conversationId, agentId, connectorId, onError }: UseChatProps) => { + const { chatService } = useOnechatServices(); + const { + services: { notifications }, + } = useKibana(); + const [status, setStatus] = useState('ready'); + const { actions } = useConversation({ conversationId }); + + const sendMessage = useCallback( + (nextMessage: string) => { + if (status === 'loading') { + return; + } + + actions.addConversationRound({ userMessage: nextMessage }); + setStatus('loading'); + + const events$ = chatService.chat({ + nextMessage, + conversationId, + agentId, + connectorId, + }); + + events$.subscribe({ + next: (event) => { + // chunk received, we append it to the chunk buffer + if (isMessageChunkEvent(event)) { + actions.addAssistantMessageChunk({ messageChunk: event.data.textChunk }); + } + + // full message received - we purge the chunk buffer + // and insert the received message into the temporary list + else if (isMessageCompleteEvent(event)) { + actions.setAssistantMessage({ assistantMessage: event.data.messageContent }); + } else if (isToolCallEvent(event)) { + const { toolCallId, toolId, args } = event.data; + actions.addToolCall({ + step: createToolCallStep({ + args, + result: '', + toolCallId, + toolId, + }), + }); + } else if (isToolResultEvent(event)) { + const { toolCallId, result } = event.data; + actions.setToolCallResult({ result, toolCallId }); + } else if (isConversationCreatedEvent(event) || isConversationUpdatedEvent(event)) { + const { conversationId: id, title } = event.data; + actions.onConversationUpdate({ conversationId: id, title }); + } + }, + complete: () => { + actions.invalidateConversation(); + setStatus('ready'); + }, + error: (err) => { + actions.invalidateConversation(); + setStatus('error'); + if (isOnechatError(err)) { + onError?.(err); + + notifications.toasts.addError(err, { + title: i18n.translate('xpack.onechat.chat.chatError.title', { + defaultMessage: 'Error loading chat response', + }), + toastMessage: `${err.code} - ${err.message}`, + }); + } + }, + }); + }, + [chatService, notifications, status, agentId, conversationId, connectorId, onError, actions] + ); + + return { + status, + sendMessage, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation.ts b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation.ts new file mode 100644 index 0000000000000..47f720adce503 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation.ts @@ -0,0 +1,136 @@ +/* + * 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 { Conversation } from '@kbn/onechat-common'; +import { + ConversationRound, + ToolCallStep, + createEmptyConversation, + isToolCallStep, +} from '@kbn/onechat-common/chat/conversation'; +import { QueryClient, QueryKey, useQuery, useQueryClient } from '@tanstack/react-query'; +import produce from 'immer'; +import { queryKeys } from '../query_keys'; +import { appPaths } from '../utils/app_paths'; +import { useNavigation } from './use_navigation'; +import { useOnechatServices } from './use_onechat_service'; + +const createActions = ({ + queryClient, + queryKey, + navigateToNewConversation, +}: { + queryClient: QueryClient; + queryKey: QueryKey; + navigateToNewConversation: ({ newConversationId }: { newConversationId: string }) => void; +}) => { + const setConversation = (updater: (conversation?: Conversation) => Conversation) => { + queryClient.setQueryData(queryKey, updater); + }; + const setCurrentRound = (updater: (conversationRound: ConversationRound) => void) => { + setConversation( + produce((draft) => { + const round = draft?.rounds?.at(-1); + if (round) { + updater(round); + } + }) + ); + }; + return { + invalidateConversation: () => { + queryClient.invalidateQueries({ queryKey }); + }, + addConversationRound: ({ userMessage }: { userMessage: string }) => { + setConversation( + produce((draft) => { + const nextRound: ConversationRound = { + userInput: { message: userMessage }, + assistantResponse: { message: '' }, + steps: [], + }; + if (!draft) { + const nextConversation = createEmptyConversation(); + nextConversation.rounds.push(nextRound); + return nextConversation; + } + draft.rounds.push(nextRound); + }) + ); + }, + addToolCall: ({ step }: { step: ToolCallStep }) => { + setCurrentRound((round) => { + round.steps.push(step); + }); + }, + setToolCallResult: ({ result, toolCallId }: { result: string; toolCallId: string }) => { + setCurrentRound((round) => { + const step = round.steps.find((s) => isToolCallStep(s) && s.toolCallId === toolCallId); + if (step) { + step.result = result; + } + }); + }, + setAssistantMessage: ({ assistantMessage }: { assistantMessage: string }) => { + setCurrentRound((round) => { + round.assistantResponse.message = assistantMessage; + }); + }, + addAssistantMessageChunk: ({ messageChunk }: { messageChunk: string }) => { + setCurrentRound((round) => { + round.assistantResponse.message += messageChunk; + }); + }, + onConversationUpdate: ({ + conversationId: id, + title, + }: { + conversationId: string; + title: string; + }) => { + const current = queryClient.getQueryData(queryKey); + if (current) { + queryClient.setQueryData( + queryKeys.conversations.byId(id), + produce(current, (draft) => { + draft.id = id; + draft.title = title; + }) + ); + } + navigateToNewConversation({ newConversationId: id }); + }, + }; +}; + +export const useConversation = ({ conversationId }: { conversationId: string | undefined }) => { + const { conversationsService } = useOnechatServices(); + const queryClient = useQueryClient(); + const queryKey = queryKeys.conversations.byId(conversationId ?? 'new'); + const { data: conversation, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + if (conversationId) { + return conversationsService.get({ conversationId }); + } + return null; + }, + }); + const { navigateToOnechatUrl } = useNavigation(); + + return { + conversation, + isLoading, + actions: createActions({ + queryClient, + queryKey, + navigateToNewConversation: ({ newConversationId }: { newConversationId: string }) => { + navigateToOnechatUrl(appPaths.chat.conversation({ conversationId: newConversationId })); + }, + }), + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation_list.ts b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation_list.ts new file mode 100644 index 0000000000000..8e68b83e58282 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_conversation_list.ts @@ -0,0 +1,32 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '../query_keys'; +import { useOnechatServices } from './use_onechat_service'; + +export const useConversationList = ({ agentId }: { agentId?: string }) => { + const { conversationsService } = useOnechatServices(); + + const { + data: conversations, + isLoading, + refetch: refresh, + } = useQuery({ + queryKey: agentId ? queryKeys.conversations.byAgent(agentId) : queryKeys.conversations.all, + queryFn: async () => { + return conversationsService.list({ agentId }); + }, + initialData: () => [], + }); + + return { + conversations, + isLoading, + refresh, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_navigation.ts b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_navigation.ts new file mode 100644 index 0000000000000..fd194b866d9db --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_navigation.ts @@ -0,0 +1,35 @@ +/* + * 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 { useCallback } from 'react'; +import { ONECHAT_APP_ID } from '../../../common/features'; +import { useKibana } from './use_kibana'; + +export const useNavigation = () => { + const { + services: { application }, + } = useKibana(); + + const navigateToOnechatUrl = useCallback( + (path: string) => { + application.navigateToApp(ONECHAT_APP_ID, { path }); + }, + [application] + ); + + const createOnechatUrl = useCallback( + (path: string) => { + return application.getUrlForApp(ONECHAT_APP_ID, { path }); + }, + [application] + ); + + return { + createOnechatUrl, + navigateToOnechatUrl, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_stick_to_bottom.ts b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_stick_to_bottom.ts new file mode 100644 index 0000000000000..e85a03e68ce45 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/hooks/use_stick_to_bottom.ts @@ -0,0 +1,54 @@ +/* + * 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 { useEffect, useState } from 'react'; + +const isAtBottom = (parent: HTMLElement) => + parent.scrollTop + parent.clientHeight >= parent.scrollHeight; + +export const useStickToBottom = ({ + defaultState, + scrollContainer, +}: { + defaultState?: boolean; + scrollContainer: HTMLDivElement | null; +}) => { + const [stickToBottom, setStickToBottom] = useState(defaultState ?? true); + + useEffect(() => { + const parent = scrollContainer?.parentElement; + if (!parent) { + return; + } + + const onScroll = () => { + setStickToBottom(isAtBottom(parent!)); + }; + + parent.addEventListener('scroll', onScroll); + + return () => { + parent.removeEventListener('scroll', onScroll); + }; + }, [scrollContainer]); + + useEffect(() => { + const parent = scrollContainer?.parentElement; + if (!parent) { + return; + } + + if (stickToBottom) { + parent.scrollTop = parent.scrollHeight; + } + }); + + return { + stickToBottom, + setStickToBottom, + }; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/pages/conversations.tsx b/x-pack/platform/plugins/shared/onechat/public/application/pages/conversations.tsx new file mode 100644 index 0000000000000..3134ccae8e464 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/pages/conversations.tsx @@ -0,0 +1,24 @@ +/* + * 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, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { OnechatConversationsView } from '../components/conversations/conversations_view'; + +const newConversationId = 'new'; + +export const OnechatConversationsPage: React.FC = () => { + const { conversationId: conversationIdParam } = useParams<{ conversationId?: string }>(); + + // TODO: Add logic to resume most recent conversation when no conversationId is provided + // For now, if no conversationId is provided, we will create a new conversation + const conversationId = useMemo(() => { + return conversationIdParam === newConversationId ? undefined : conversationIdParam; + }, [conversationIdParam]); + + return ; +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/query_keys.ts b/x-pack/platform/plugins/shared/onechat/public/application/query_keys.ts new file mode 100644 index 0000000000000..5ed2d412f5c78 --- /dev/null +++ b/x-pack/platform/plugins/shared/onechat/public/application/query_keys.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +/** + * Query keys for react-query + */ +export const queryKeys = { + conversations: { + all: ['conversations'] as const, + byAgent: (agentId: string) => ['conversations', 'list', { agentId }], + byId: (conversationId: string) => ['conversations', conversationId], + }, +}; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/routes.tsx b/x-pack/platform/plugins/shared/onechat/public/application/routes.tsx index a8f9e50e6ed19..afd0b1aed221f 100644 --- a/x-pack/platform/plugins/shared/onechat/public/application/routes.tsx +++ b/x-pack/platform/plugins/shared/onechat/public/application/routes.tsx @@ -9,14 +9,14 @@ import { Routes, Route } from '@kbn/shared-ux-router'; import React from 'react'; import { useUiSetting } from '@kbn/kibana-react-plugin/public'; import { OnechatToolsPage } from './pages/tools'; -import { OnechatConversationsPage } from './pages/chat'; +import { OnechatConversationsPage } from './pages/conversations'; import { ONECHAT_TOOLS_UI_SETTING_ID } from '../../common/constants'; export const OnechatRoutes: React.FC<{}> = () => { const isToolsPageEnabled = useUiSetting(ONECHAT_TOOLS_UI_SETTING_ID, false); return ( - + {isToolsPageEnabled && ( @@ -24,6 +24,10 @@ export const OnechatRoutes: React.FC<{}> = () => { )} + {/* Default to conversations page */} + + + ); }; diff --git a/x-pack/platform/plugins/shared/onechat/public/application/pages/chat.tsx b/x-pack/platform/plugins/shared/onechat/public/application/utils/app_paths.ts similarity index 56% rename from x-pack/platform/plugins/shared/onechat/public/application/pages/chat.tsx rename to x-pack/platform/plugins/shared/onechat/public/application/utils/app_paths.ts index 847569dfe59a0..2e5ebb87198b8 100644 --- a/x-pack/platform/plugins/shared/onechat/public/application/pages/chat.tsx +++ b/x-pack/platform/plugins/shared/onechat/public/application/utils/app_paths.ts @@ -5,8 +5,11 @@ * 2.0. */ -import React from 'react'; - -export const OnechatConversationsPage = () => { - return
OnechatConversationsPage
; +export const appPaths = { + chat: { + new: '/conversations/new', + conversation: ({ conversationId }: { conversationId: string }) => { + return `/conversations/${conversationId}`; + }, + }, }; diff --git a/x-pack/platform/plugins/shared/onechat/public/services/chat/chat_service.ts b/x-pack/platform/plugins/shared/onechat/public/services/chat/chat_service.ts index 2fe0e96fb865e..363dcdfab9ce4 100644 --- a/x-pack/platform/plugins/shared/onechat/public/services/chat/chat_service.ts +++ b/x-pack/platform/plugins/shared/onechat/public/services/chat/chat_service.ts @@ -23,7 +23,8 @@ export class ChatService { chat({ agentId, connectorId, conversationId, nextMessage }: ChatParams): Observable { return defer(() => { - return this.http.post('/internal/onechat/chat?stream=true', { + return this.http.post('/internal/onechat/chat', { + query: { stream: true }, asResponse: true, rawResponse: true, body: JSON.stringify({ agentId, connectorId, conversationId, nextMessage }), diff --git a/x-pack/platform/plugins/shared/onechat/public/services/conversations/conversations_service.ts b/x-pack/platform/plugins/shared/onechat/public/services/conversations/conversations_service.ts index 8db2df9fb3e51..f6005a09bcc8d 100644 --- a/x-pack/platform/plugins/shared/onechat/public/services/conversations/conversations_service.ts +++ b/x-pack/platform/plugins/shared/onechat/public/services/conversations/conversations_service.ts @@ -6,9 +6,12 @@ */ import type { HttpSetup } from '@kbn/core-http-browser'; -import { toSerializedAgentIdentifier } from '@kbn/onechat-common'; +import { Conversation, toSerializedAgentIdentifier } from '@kbn/onechat-common'; import type { ListConversationsResponse } from '../../../common/http_api/conversations'; -import type { ConversationListOptions } from '../../../common/conversations'; +import type { + ConversationListOptions, + ConversationGetOptions, +} from '../../../common/conversations'; export class ConversationsService { private readonly http: HttpSetup; @@ -18,8 +21,18 @@ export class ConversationsService { } async list({ agentId }: ConversationListOptions) { - return await this.http.post('/internal/onechat/conversations', { - body: JSON.stringify({ agentId: agentId ? toSerializedAgentIdentifier(agentId) : undefined }), - }); + const response = await this.http.post( + '/internal/onechat/conversations', + { + body: JSON.stringify({ + agentId: agentId ? toSerializedAgentIdentifier(agentId) : undefined, + }), + } + ); + return response.conversations; + } + + async get({ conversationId }: ConversationGetOptions) { + return await this.http.get(`/internal/onechat/conversations/${conversationId}`); } } diff --git a/x-pack/platform/plugins/shared/onechat/server/routes/conversations.ts b/x-pack/platform/plugins/shared/onechat/server/routes/conversations.ts index 1344a50c2362e..d6af69feae819 100644 --- a/x-pack/platform/plugins/shared/onechat/server/routes/conversations.ts +++ b/x-pack/platform/plugins/shared/onechat/server/routes/conversations.ts @@ -44,4 +44,29 @@ export function registerConversationRoutes({ }); }) ); + + router.get( + { + path: '/internal/onechat/conversations/{conversationId}', + security: { + authz: { requiredPrivileges: [apiPrivileges.readOnechat] }, + }, + validate: { + params: schema.object({ + conversationId: schema.string(), + }), + }, + }, + wrapHandler(async (ctx, request, response) => { + const { conversations: conversationsService } = getInternalServices(); + const { conversationId } = request.params; + + const client = await conversationsService.getScopedClient({ request }); + const conversation = await client.get(conversationId); + + return response.ok({ + body: conversation, + }); + }) + ); }