mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
180f90a65c
commit
3ef270c167
26 changed files with 1494 additions and 13 deletions
|
@ -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: [],
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -20,3 +20,7 @@ export type ConversationUpdateRequest = Pick<Conversation, 'id'> &
|
|||
export interface ConversationListOptions {
|
||||
agentId?: AgentIdentifier;
|
||||
}
|
||||
|
||||
export interface ConversationGetOptions {
|
||||
conversationId: string;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 ?? ''} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 }));
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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} />;
|
||||
};
|
|
@ -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],
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}`;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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 }),
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue