[OneChat] Simple Chat UI (#222816)

## Summary


https://github.com/user-attachments/assets/47cf7b8e-fb43-43c8-b6b4-e7a379660d80


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] 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)
- [ ] 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.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] 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: Jedr Blaszyk <jedr.blaszyk@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zachary Parikh 2025-06-27 09:30:15 -04:00 committed by GitHub
parent 180f90a65c
commit 3ef270c167
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1494 additions and 13 deletions

View file

@ -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';
/**
@ -67,6 +72,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;
};
@ -114,3 +126,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: [],
};
};

View file

@ -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
```

View file

@ -20,3 +20,7 @@ export type ConversationUpdateRequest = Pick<Conversation, 'id'> &
export interface ConversationListOptions {
agentId?: AgentIdentifier;
}
export interface ConversationGetOptions {
conversationId: string;
}

View file

@ -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<ConversationProps> = ({ agentId, conversationId }) => {
const { conversation } = useConversation({ conversationId });
const { sendMessage } = useChat({
conversationId,
agentId,
});
const theme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(theme);
const scrollContainerRef = useRef<HTMLDivElement | null>(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 <NewConversationPrompt onSubmit={onSubmit} />;
}
return (
<>
<EuiFlexItem grow className={scrollContainerClassName(scrollBarStyles)}>
<div ref={scrollContainerRef} className={fullHeightClassName}>
<EuiPanel hasBorder={false} hasShadow={false} className={conversationPanelClass}>
<ConversationRounds conversationRounds={conversation?.rounds ?? []} />
</EuiPanel>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConversationInputForm disabled={!agentId} loading={false} onSubmit={onSubmit} />
</EuiFlexItem>
</>
);
};

View file

@ -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<ConversationHeaderProps> = ({ 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 (
<EuiFlexItem grow={false}>
<EuiPanel
hasBorder={false}
hasShadow={false}
borderRadius="none"
color="subdued"
className={containerClass}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow>
<EuiTitle>
<EuiSkeletonTitle size="m" isLoading={conversationId !== undefined && isConvLoading}>
<h3 className={conversationTitleClass}>
{conversation?.title || chatCommonLabels.chat.conversations.newConversationLabel}
</h3>
</EuiSkeletonTitle>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
);
};

View file

@ -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<ConversationInputFormProps> = ({
disabled,
loading,
onSubmit,
}) => {
const [message, setMessage] = useState<string>('');
const { euiTheme } = useEuiTheme();
const handleSubmit = useCallback(() => {
if (loading || !message.trim()) {
return;
}
onSubmit(message);
setMessage('');
}, [message, loading, onSubmit]);
const handleChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(event.currentTarget.value);
}, []);
const handleTextAreaKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<EuiFlexGroup
gutterSize="s"
responsive={false}
alignItems="center"
justifyContent="center"
className={topContainerClass}
>
<EuiFlexItem className={inputFlexItemClass}>
<EuiTextArea
data-test-subj="onechatAppConversationInputFormTextArea"
fullWidth
rows={1}
value={message}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
placeholder={chatCommonLabels.userInputBox.placeholder}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label="Submit"
data-test-subj="onechatAppConversationInputFormSubmitButton"
iconType="kqlFunction"
display="fill"
size="m"
onClick={handleSubmit}
disabled={loading || disabled}
isLoading={loading}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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<ConversationPanelProps> = ({
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 (
<EuiPanel
paddingSize="m"
hasShadow={false}
color="transparent"
className={pageSectionContentClassName}
>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false} className={sectionBlockPaddingCLassName}>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="list" />
</EuiFlexItem>
<EuiFlexItem grow>
<EuiText size="s" className={titleClassName}>
{chatCommonLabels.chat.conversations.conversationsListTitle}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow className={scrollContainerClassName}>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
{/* Todo: Add conversation groups */}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule size="full" margin="none" className={createButtonRuleClassName} />
</EuiFlexItem>
<EuiFlexItem grow={false} className={sectionBlockPaddingCLassName}>
<EuiButton
iconType="newChat"
onClick={() => {
onNewConversationSelect?.();
}}
>
{chatCommonLabels.chat.conversations.newConversationLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -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 = () => <span key="cursor" className={classNames(cursorCss, '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 (
<>
<EuiCodeBlock>{props.value}</EuiCodeBlock>
<EuiSpacer size="m" />
</>
);
},
esql: (props) => {
return (
<>
<EuiCodeBlock>{props.value}</EuiCodeBlock>
<EuiSpacer size="m" />
</>
);
},
table: (props) => (
<>
<EuiTable
{...props}
className={css`
.euiTableCellContent__text {
white-space: normal;
}
`}
/>
<EuiSpacer size="m" />
</>
),
th: (props) => {
const { children, ...rest } = props;
return <EuiTableHeaderCell {...rest}>{children}</EuiTableHeaderCell>;
},
tr: (props) => <EuiTableRow {...props} />,
td: (props) => {
const { children, ...rest } = props;
return (
<EuiTableRowCell truncateText={true} {...rest}>
{children}
</EuiTableRowCell>
);
},
};
return {
parsingPluginList: [loadingCursorPlugin, esqlLanguagePlugin, ...parsingPlugins],
processingPluginList: processingPlugins,
};
}, []);
return (
<EuiText size="s" className={containerClassName}>
<EuiMarkdownFormat
textSize="s"
parsingPluginList={parsingPluginList}
processingPluginList={processingPluginList}
>
{content}
</EuiMarkdownFormat>
</EuiText>
);
}

View file

@ -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<ConversationRoundsProps> = ({ conversationRounds }) => {
return (
<>
{conversationRounds.map((round, index) => {
return <Round key={index} round={round} />;
})}
</>
);
};

View file

@ -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<RoundProps> = ({ 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 (
<EuiPanel
className={rootPanelClass}
borderRadius="none"
paddingSize="none"
hasShadow={false}
hasBorder={true}
>
<div className={userTextContainerClass}>
<EuiText color="subdued" size="m" className={userMessageTextClass}>
{userInput.message}
</EuiText>
</div>
<div className={tabContentPanelClass}>
<RoundAnswer key={`round-answer-tab`} round={round} />
</div>
</EuiPanel>
);
};

View file

@ -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<RoundAnswerProps> = ({ 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 (
<div key={step.toolCallId}>
<EuiPanel className={toolCallPanelClass} hasShadow={false} hasBorder={true}>
<div className={stepHeaderClass}>
<EuiIcon type="wrench" color="primary" />
<EuiText size="s" color="subdued">
Tool: {step.toolId.toolId}
</EuiText>
</div>
<EuiSpacer size="xs" />
<EuiAccordion
id={`args-${step.toolCallId}`}
buttonContent={
<EuiText size="xs" color="subdued">
Tool call args
</EuiText>
}
paddingSize="s"
>
<div className={codeBlockClass}>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
isCopyable={false}
transparentBackground
>
{JSON.stringify(step.args, null, 2)}
</EuiCodeBlock>
</div>
</EuiAccordion>
<EuiSpacer size="s" />
{step.result ? (
<div className={codeBlockClass}>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
isCopyable={false}
transparentBackground
>
{step.result}
</EuiCodeBlock>
</div>
) : (
<EuiText size="s" color="subdued">
No result available
</EuiText>
)}
</EuiPanel>
<EuiSpacer size="m" />
</div>
);
}
return null;
})}
<ChatMessageText content={assistantResponse?.message ?? ''} />
</>
);
};

View file

@ -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 (
<KibanaPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="onechatPageConversations"
grow={false}
panelled={false}
>
<KibanaPageTemplate.Sidebar paddingSize="none" minWidth={280}>
<ConversationPanel
onNewConversationSelect={() => {
navigateToOnechatUrl(appPaths.chat.new);
}}
/>
</KibanaPageTemplate.Sidebar>
<KibanaPageTemplate.Section paddingSize="none" grow contentProps={{ css: 'height: 100%' }}>
<EuiFlexGroup
className={pageSectionContentClassName}
direction="column"
gutterSize="none"
justifyContent="center"
responsive={false}
>
<ConversationHeader conversationId={conversationId} />
<Conversation agentId={OneChatDefaultAgentId} conversationId={conversationId} />
</EuiFlexGroup>
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -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',
}),
},
};

View file

@ -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<NewConversationPromptProps> = ({ onSubmit }) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState<string>('');
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<HTMLTextAreaElement>) => {
setMessage(event.currentTarget.value);
}, []);
const handleTextAreaKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!event.shiftKey && event.key === keys.ENTER) {
event.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
return (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false} className={containerClass}>
<EuiPanel hasBorder={true} hasShadow={false} borderRadius="none" paddingSize="xl">
<EuiFlexGroup
direction="column"
gutterSize="s"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem className={inputContainerClass}>
<EuiFlexGroup
gutterSize="s"
responsive={false}
alignItems="center"
justifyContent="center"
>
<EuiFlexItem className={inputFlexItemClass}>
<EuiTextArea
inputRef={inputRef}
data-test-subj="onechatAppChatNewConvTextArea"
fullWidth
rows={1}
resize="vertical"
value={message}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
placeholder={chatCommonLabels.userInputBox.placeholder}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label="Submit"
data-test-subj="onechatAppChatNewConvSubmitButton"
iconType="kqlFunction"
display="fill"
size="m"
onClick={handleSubmit}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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<OnechatErrorCode>) => void;
}
export const useChat = ({ conversationId, agentId, connectorId, onError }: UseChatProps) => {
const { chatService } = useOnechatServices();
const {
services: { notifications },
} = useKibana();
const [status, setStatus] = useState<ChatStatus>('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,
};
};

View file

@ -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<Conversation>(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<Conversation>(queryKey);
if (current) {
queryClient.setQueryData<Conversation>(
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 }));
},
}),
};
};

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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 <OnechatConversationsView conversationId={conversationId} />;
};

View file

@ -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],
},
};

View file

@ -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<boolean>(ONECHAT_TOOLS_UI_SETTING_ID, false);
return (
<Routes>
<Route path="/conversations">
<Route path="/conversations/:conversationId">
<OnechatConversationsPage />
</Route>
{isToolsPageEnabled && (
@ -24,6 +24,10 @@ export const OnechatRoutes: React.FC<{}> = () => {
<OnechatToolsPage />
</Route>
)}
{/* Default to conversations page */}
<Route path="/">
<OnechatConversationsPage />
</Route>
</Routes>
);
};

View file

@ -5,8 +5,11 @@
* 2.0.
*/
import React from 'react';
export const OnechatConversationsPage = () => {
return <div>OnechatConversationsPage</div>;
export const appPaths = {
chat: {
new: '/conversations/new',
conversation: ({ conversationId }: { conversationId: string }) => {
return `/conversations/${conversationId}`;
},
},
};

View file

@ -29,7 +29,8 @@ export class ChatService {
mode,
}: ChatParams): Observable<ChatEvent> {
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, mode, connectorId, conversationId, nextMessage }),

View file

@ -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<ListConversationsResponse>('/internal/onechat/conversations', {
body: JSON.stringify({ agentId: agentId ? toSerializedAgentIdentifier(agentId) : undefined }),
});
const response = await this.http.post<ListConversationsResponse>(
'/internal/onechat/conversations',
{
body: JSON.stringify({
agentId: agentId ? toSerializedAgentIdentifier(agentId) : undefined,
}),
}
);
return response.conversations;
}
async get({ conversationId }: ConversationGetOptions) {
return await this.http.get<Conversation>(`/internal/onechat/conversations/${conversationId}`);
}
}

View file

@ -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,
});
})
);
}