[workchat] implement m1 chat design (#217465)

## Summary

Implements the m1 design for the chat page and components

*Note: only covers the parts that are functionally present in the app
atm*

### Design demo


https://github.com/user-attachments/assets/16f64a51-16ad-45c5-9d4b-77c31598427a

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2025-04-10 16:05:15 +02:00 committed by GitHub
parent c2596a1f61
commit bc1124118c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1554 additions and 1107 deletions

View file

@ -10,6 +10,10 @@ import type { Client as McpBaseClient } from '@modelcontextprotocol/sdk/client/i
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { MaybePromise } from '@kbn/utility-types';
/**
* Internal representation of an MCP tool.
* Mostly used to abstract the underlying MCP implementation we're using for "internal" servers
*/
export interface McpServerTool<RunInput extends ZodRawShape = ZodRawShape> {
name: string;
description: string;

View file

@ -10,10 +10,12 @@ import { css } from '@emotion/css';
import { EuiFlexItem, EuiPanel, useEuiTheme, euiScrollBarStyles } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/core/public';
import { ConversationEventChanges } from '../../../../common/chat_events';
import { useChat } from '../../hooks/use_chat';
import { useConversation } from '../../hooks/use_conversation';
import { useStickToBottom } from '../../hooks/use_stick_to_bottom';
import { ChatInputForm } from './chat_input_form';
import { ChatConversation } from './chat_conversation';
import { ChatConversation } from './conversation/chat_conversation';
import { ChatNewConversationPrompt } from './chat_new_conversation_prompt';
interface ChatProps {
agentId: string;
@ -27,8 +29,11 @@ const fullHeightClassName = css`
height: 100%;
`;
const panelClassName = css`
const conversationPanelClass = css`
min-height: 100%;
max-width: 850px;
margin-left: auto;
margin-right: auto;
`;
const scrollContainerClassName = (scrollBarStyles: string) => css`
@ -43,13 +48,24 @@ export const Chat: React.FC<ChatProps> = ({
onConversationUpdate,
connectorId,
}) => {
const { sendMessage, conversationEvents, progressionEvents, chatStatus } = useConversation({
const { conversation } = useConversation({ conversationId });
const {
sendMessage,
conversationEvents,
setConversationEvents,
progressionEvents,
status: chatStatus,
} = useChat({
conversationId,
connectorId,
agentId,
onConversationUpdate,
});
useEffect(() => {
setConversationEvents(conversation?.events ?? []);
}, [conversation, setConversationEvents]);
const theme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(theme);
@ -72,11 +88,15 @@ export const Chat: React.FC<ChatProps> = ({
[sendMessage, setStickToBottom]
);
if (!conversationId && conversationEvents.length === 0) {
return <ChatNewConversationPrompt agentId={agentId} onSubmit={onSubmit} />;
}
return (
<>
<EuiFlexItem grow className={scrollContainerClassName(scrollBarStyles)}>
<div ref={scrollContainerRef} className={fullHeightClassName}>
<EuiPanel hasBorder={false} hasShadow={false} className={panelClassName}>
<EuiPanel hasBorder={false} hasShadow={false} className={conversationPanelClass}>
<ChatConversation
conversationEvents={conversationEvents}
progressionEvents={progressionEvents}

View file

@ -1,47 +0,0 @@
/*
* 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 { EuiCommentList } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/core/public';
import type { ConversationEvent } from '../../../../common/conversation_events';
import type { ProgressionEvent } from '../../../../common/chat_events';
import { getChartConversationItems } from '../../utils/get_chart_conversation_items';
import { ChatConversationItem } from './chat_conversation_item';
import type { ChatStatus } from '../../hooks/use_chat';
interface ChatConversationProps {
conversationEvents: ConversationEvent[];
progressionEvents: ProgressionEvent[];
chatStatus: ChatStatus;
currentUser: AuthenticatedUser | undefined;
}
export const ChatConversation: React.FC<ChatConversationProps> = ({
conversationEvents,
progressionEvents,
chatStatus,
currentUser,
}) => {
const conversationItems = useMemo(() => {
return getChartConversationItems({ conversationEvents, progressionEvents, chatStatus });
}, [conversationEvents, progressionEvents, chatStatus]);
return (
<EuiCommentList>
{conversationItems.map((conversationItem) => {
return (
<ChatConversationItem
key={conversationItem.id}
item={conversationItem}
currentUser={currentUser}
/>
);
})}
</EuiCommentList>
);
};

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { AuthenticatedUser } from '@kbn/core/public';
import {
type ConversationItem,
isUserMessageItem,
isAssistantMessageItem,
isToolCallItem,
isProgressionItem,
} from '../../utils/conversation_items';
import { ChatConversationMessage } from './chat_conversation_message';
import { ChatConversationToolCall } from './chat_conversation_tool_call';
import { ChatConversationProgression } from './chat_conversation_progression';
interface ChatConversationItemProps {
item: ConversationItem;
currentUser: AuthenticatedUser | undefined;
}
export const ChatConversationItem: React.FC<ChatConversationItemProps> = ({
item,
currentUser,
}) => {
if (isUserMessageItem(item) || isAssistantMessageItem(item)) {
return <ChatConversationMessage message={item} currentUser={currentUser} />;
}
if (isToolCallItem(item)) {
return <ChatConversationToolCall toolCall={item} />;
}
if (isProgressionItem(item)) {
return <ChatConversationProgression progress={item} />;
}
return undefined;
};

View file

@ -1,105 +0,0 @@
/*
* 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 { EuiComment, EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ContentRef } from '@kbn/wci-common';
import type { AuthenticatedUser } from '@kbn/core/public';
import { ChatMessageText } from './chat_message_text';
import { ChatMessageAvatar } from './chat_message_avatar';
import {
type UserMessageConversationItem,
type AssistantMessageConversationItem,
isUserMessageItem,
isAssistantMessageItem,
} from '../../utils/conversation_items';
type UserOrAssistantMessageItem = UserMessageConversationItem | AssistantMessageConversationItem;
interface ChatConversationMessageProps {
message: UserOrAssistantMessageItem;
currentUser: AuthenticatedUser | undefined;
}
const getUserLabel = (item: UserOrAssistantMessageItem) => {
if (isUserMessageItem(item)) {
return i18n.translate('xpack.workchatApp.chat.messages.userLabel', {
defaultMessage: 'You',
});
}
return i18n.translate('xpack.workchatApp.chat.messages.assistantLabel', {
defaultMessage: 'WorkChat',
});
};
export const ChatConversationMessage: React.FC<ChatConversationMessageProps> = ({
message,
currentUser,
}) => {
const userMessage = useMemo(() => {
return isUserMessageItem(message);
}, [message]);
const messageContent = useMemo(() => {
return message.message.content;
}, [message]);
const citations = isAssistantMessageItem(message) ? message.message.citations : [];
return (
<EuiComment
username={getUserLabel(message)}
timelineAvatar={
<ChatMessageAvatar
eventType={userMessage ? 'user' : 'assistant'}
loading={message.loading}
currentUser={currentUser}
/>
}
event=""
eventColor={userMessage ? 'primary' : 'subdued'}
actions={<></>}
>
<EuiPanel hasShadow={false} paddingSize="s">
<ChatMessageText content={messageContent} loading={message.loading} />
{citations.length > 0 && (
<>
<EuiSpacer />
<ChatMessageCitations citations={citations} />
</>
)}
</EuiPanel>
</EuiComment>
);
};
export const ChatMessageCitations: React.FC<{ citations: ContentRef[] }> = ({ citations }) => {
const renderCitation = (citation: ContentRef) => {
return (
<EuiFlexItem grow={false}>
<EuiPanel hasShadow={false} hasBorder={true} paddingSize="s">
<EuiText size="s">Document</EuiText>
<EuiText size="s" color="subdued">
ID: {citation.contentId}
</EuiText>
<EuiText size="s" color="subdued">
Integration: {citation.sourceId}
</EuiText>
</EuiPanel>
</EuiFlexItem>
);
};
return (
<EuiPanel hasShadow={false} hasBorder={true} paddingSize="s" grow={false}>
<EuiText>Sources</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup>{citations.map(renderCitation)}</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -1,103 +0,0 @@
/*
* 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, keyframes } from '@emotion/react';
import { EuiTimelineItem, EuiLoadingElastic, EuiAvatar } from '@elastic/eui';
import type { ProgressionEvent } from '../../../../common/chat_events';
import type { ProgressionConversationItem } from '../../utils/conversation_items';
interface ChatConversationMessageProps {
progress: ProgressionConversationItem;
}
export const ChatConversationProgression: React.FC<ChatConversationMessageProps> = ({
progress,
}) => {
const { progressionEvents } = progress;
const getText = (event: ProgressionEvent): string => {
switch (event.data.step) {
case 'planning':
return 'Thinking about which integration to use';
case 'retrieval':
return 'Calling relevant integrations';
case 'analysis':
return 'Analysing the results';
case 'generate_summary':
return 'Summarizing content';
default:
return 'Working';
}
};
const lastEvent = progressionEvents[progressionEvents.length - 1];
const icon = <EuiAvatar name="loading" color="subdued" iconType={EuiLoadingElastic} />;
// search node dot
return (
<EuiTimelineItem icon={icon}>
<FancyLoadingText text={getText(lastEvent)} />
</EuiTimelineItem>
);
};
interface FancyLoadingTextProps {
text: string;
className?: string;
}
interface FancyLoadingTextProps {
text: string;
className?: string;
}
const gradientAnimation = keyframes`
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
`;
const dotsAnimation = keyframes`
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
100% { content: ""; }
`;
const fancyTextStyle = css`
background: linear-gradient(270deg, #ec4899, #8b5cf6, #3b82f6);
background-size: 200% 200%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: ${gradientAnimation} 3s ease infinite;
font-weight: bold;
display: inline-block;
`;
const dotsStyle = css`
&::after {
content: '';
animation: ${dotsAnimation} 1.2s steps(4, end) infinite;
display: inline-block;
white-space: pre;
}
`;
const FancyLoadingText = ({ text, className = '' }: FancyLoadingTextProps) => {
return (
<span css={[fancyTextStyle, dotsStyle]} className={className}>
{text}
</span>
);
};

View file

@ -1,37 +0,0 @@
/*
* 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 { EuiComment } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ToolCallConversationItem } from '../../utils/conversation_items';
import { useIntegrationToolView } from '../../hooks/use_integration_tool_view';
import { ChatMessageAvatar } from './chat_message_avatar';
interface ChatConversationMessageProps {
toolCall: ToolCallConversationItem;
}
const assistantLabel = i18n.translate('xpack.workchatApp.chat.messages.assistantLabel', {
defaultMessage: 'WorkChat',
});
export const ChatConversationToolCall: React.FC<ChatConversationMessageProps> = ({ toolCall }) => {
const ToolView = useIntegrationToolView(toolCall.toolCall.toolName);
return (
<EuiComment
username={assistantLabel}
timelineAvatar={
<ChatMessageAvatar eventType="tool" loading={toolCall.loading} currentUser={undefined} />
}
event={<ToolView toolCall={toolCall.toolCall} toolResult={toolCall.toolResult} />}
eventColor="transparent"
actions={<></>}
/>
);
};

View file

@ -1,66 +0,0 @@
/*
* 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, EuiTextColor } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IntegrationToolComponentProps } from '@kbn/wci-browser';
const bold = css`
font-weight: bold;
`;
const italic = css`
font-style: italic;
`;
/**
* Component used as default rendered for the `useIntegrationToolView` hook.
*/
export const ChatDefaultToolCallRendered: React.FC<
Omit<IntegrationToolComponentProps, 'integration'>
> = ({ toolCall, toolResult }) => {
const toolNode = (
<EuiTextColor className={bold} color="success">
{toolCall.toolName}
</EuiTextColor>
);
const argsNode = (
<EuiTextColor className={italic} color="accent">
{JSON.stringify(toolCall.args)}
</EuiTextColor>
);
if (toolResult) {
return (
<EuiText size="s">
<FormattedMessage
id="xpack.workchatApp.chat.toolCall.calledToolLabel"
defaultMessage="called tool {tool} with arguments {args}"
values={{
tool: toolNode,
args: argsNode,
}}
/>
</EuiText>
);
} else {
return (
<EuiText size="s">
<FormattedMessage
id="xpack.workchatApp.chat.toolCall.callingToolLabel"
defaultMessage="is calling tool {tool} with arguments {args}"
values={{
tool: toolNode,
args: argsNode,
}}
/>
</EuiText>
);
}
};

View file

@ -1,46 +0,0 @@
/*
* 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 { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanel } from '@elastic/eui';
import type { Agent } from '../../../../common/agents';
import { ChatHeaderConnectorSelector } from './chat_header_connector_selector';
interface ChatHeaderProps {
agent?: Agent;
connectorId: string | undefined;
onConnectorChange: (connectorId: string) => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
agent,
connectorId,
onConnectorChange,
}) => {
return (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={true} hasShadow={false}>
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiTitle>
<h2>{agent?.name}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ChatHeaderConnectorSelector
connectorId={connectorId}
onConnectorChange={onConnectorChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>You know, for chat!</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
);
};

View file

@ -1,77 +0,0 @@
/*
* 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, useEffect, useCallback } from 'react';
import { EuiSuperSelect, EuiText, useEuiTheme, EuiSuperSelectOption } from '@elastic/eui';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { css } from '@emotion/css';
import { useConnectors } from '../../hooks/use_connectors';
const selectInputDisplayClassName = css`
margin-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
`;
interface ChatHeaderConnectorSelectorProps {
connectorId: string | undefined;
onConnectorChange: (connectorId: string) => void;
}
export const ChatHeaderConnectorSelector: React.FC<ChatHeaderConnectorSelectorProps> = ({
connectorId,
onConnectorChange,
}) => {
const { connectors } = useConnectors();
const { euiTheme } = useEuiTheme();
const [lastSelectedConnectorId, setLastSelectedConnectorId] = useLocalStorage<string>(
'workchat.lastSelectedConnectorId'
);
useEffect(() => {
if (connectors.length && !connectorId) {
onConnectorChange(lastSelectedConnectorId ?? connectors[0].connectorId);
}
}, [connectorId, connectors, onConnectorChange, lastSelectedConnectorId]);
const onValueChange = useCallback(
(newConnectorId: string) => {
onConnectorChange(newConnectorId);
setLastSelectedConnectorId(newConnectorId);
},
[onConnectorChange, setLastSelectedConnectorId]
);
const options = useMemo(() => {
return connectors.map<EuiSuperSelectOption<string>>((connector) => {
return {
value: connector.connectorId,
inputDisplay: (
<EuiText
className={selectInputDisplayClassName}
size="s"
color={euiTheme.colors.textPrimary}
>
{connector.name}
</EuiText>
),
};
});
}, [connectors, euiTheme]);
return (
<EuiSuperSelect
compressed={true}
data-test-subj="connector-selector"
hasDividers={true}
options={options}
valueOfSelected={connectorId}
onChange={onValueChange}
popoverProps={{ panelMinWidth: 250, anchorPosition: 'downRight' }}
/>
);
};

View file

@ -6,15 +6,16 @@
*/
import React, { useCallback, useState, KeyboardEvent } from 'react';
import { css } from '@emotion/css';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiTextArea,
EuiPanel,
keys,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { chatCommonLabels } from './i18n';
interface ChatInputFormProps {
disabled: boolean;
@ -24,6 +25,7 @@ interface ChatInputFormProps {
export const ChatInputForm: React.FC<ChatInputFormProps> = ({ disabled, loading, onSubmit }) => {
const [message, setMessage] = useState<string>('');
const { euiTheme } = useEuiTheme();
const handleSubmit = useCallback(() => {
if (loading || !message.trim()) {
@ -48,34 +50,45 @@ export const ChatInputForm: React.FC<ChatInputFormProps> = ({ disabled, loading,
[handleSubmit]
);
const topContainerClass = css`
padding-bottom: ${euiTheme.size.m};
background-color: ${euiTheme.colors.backgroundBasePlain};
`;
const inputFlexItemClass = css`
max-width: 900px;
`;
return (
<EuiPanel paddingSize="s">
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem>
<EuiTextArea
data-test-subj="workchatAppChatInputFormTextArea"
fullWidth
rows={1}
value={message}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
placeholder={i18n.translate('xpack.workchatApp.chatInputForm.placeholder', {
defaultMessage: 'Ask anything',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="workchatAppChatInputFormSubmitButton"
iconType="kqlFunction"
onClick={handleSubmit}
disabled={loading || disabled}
isLoading={loading}
display="base"
size="m"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiFlexGroup
gutterSize="s"
responsive={false}
alignItems="center"
justifyContent="center"
className={topContainerClass}
>
<EuiFlexItem className={inputFlexItemClass}>
<EuiTextArea
data-test-subj="workchatAppChatInputFormTextArea"
fullWidth
rows={1}
value={message}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
placeholder={chatCommonLabels.userInputBox.placeholder}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="workchatAppChatInputFormSubmitButton"
iconType="kqlFunction"
display="fill"
size="m"
onClick={handleSubmit}
disabled={loading || disabled}
isLoading={loading}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -1,33 +0,0 @@
/*
* 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 { EuiAvatar, EuiLoadingElastic } from '@elastic/eui';
import { UserAvatar } from '@kbn/user-profile-components';
import type { AuthenticatedUser } from '@kbn/core/public';
import { AssistantAvatar } from '@kbn/ai-assistant-icon';
interface ChatMessageAvatarProps {
eventType: 'user' | 'assistant' | 'tool';
currentUser: Pick<AuthenticatedUser, 'full_name' | 'username'> | undefined;
loading: boolean;
}
export function ChatMessageAvatar({ eventType, currentUser, loading }: ChatMessageAvatarProps) {
if (loading) {
return <EuiAvatar name="loading" color="subdued" iconType={EuiLoadingElastic} />;
}
switch (eventType) {
case 'user':
return <UserAvatar user={currentUser} size="m" />;
case 'assistant':
return <AssistantAvatar name="WorkChat" color="subdued" size="m" />;
case 'tool':
return <EuiAvatar name="WorkChat" iconType="managementApp" color="subdued" size="m" />;
}
}

View file

@ -0,0 +1,150 @@
/*
* 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,
EuiAvatar,
EuiText,
EuiTextArea,
EuiButtonIcon,
useEuiTheme,
keys,
} from '@elastic/eui';
import { useAgent } from '../../hooks/use_agent';
import { chatCommonLabels } from './i18n';
interface ChatNewConversationPromptProps {
agentId: string;
onSubmit: (message: string) => void;
}
export const ChatNewConversationPrompt: React.FC<ChatNewConversationPromptProps> = ({
agentId,
onSubmit,
}) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState<string>('');
const { euiTheme } = useEuiTheme();
const { agent } = useAgent({ agentId });
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 agentNameClassName = css`
font-weight: ${euiTheme.font.weight.semiBold};
`;
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>
<EuiAvatar
name={agent?.name ?? chatCommonLabels.assistant.defaultNameLabel}
size="xl"
type="user"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="m" className={agentNameClassName}>
{agent?.name}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" color="subdued">
{agent?.description}
</EuiText>
</EuiFlexItem>
<EuiFlexItem className={inputContainerClass}>
<EuiFlexGroup
gutterSize="s"
responsive={false}
alignItems="center"
justifyContent="center"
>
<EuiFlexItem className={inputFlexItemClass}>
<EuiTextArea
inputRef={inputRef}
data-test-subj="workchatAppChatNewConvTextArea"
fullWidth
rows={1}
resize="vertical"
value={message}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
placeholder={chatCommonLabels.userInputBox.placeholder}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="workchatAppChatNewConvSubmitButton"
iconType="kqlFunction"
display="fill"
size="m"
onClick={handleSubmit}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
return;
};

View file

@ -7,28 +7,16 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexGroup, useEuiTheme } from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { ConversationEventChanges } from '../../../../common/chat_events';
import { Chat } from './chat';
import { ChatHeader } from './chat_header';
import { ConversationList } from './conversation_list';
import { useCurrentUser } from '../../hooks/use_current_user';
import { useConversationList } from '../../hooks/use_conversation_list';
import { useKibana } from '../../hooks/use_kibana';
import { useAgent } from '../../hooks/use_agent';
const newConversationId = 'new';
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)));
`;
import { useNavigation } from '../../hooks/use_navigation';
import { appPaths } from '../../app_paths';
import { ConversationPanel } from './conversations_panel/conversation_panel';
import { ChatHeader } from './header_bar/chat_header';
import { Chat } from './chat';
interface WorkchatChatViewProps {
agentId: string;
@ -36,23 +24,32 @@ interface WorkchatChatViewProps {
}
export const WorkchatChatView: React.FC<WorkchatChatViewProps> = ({ agentId, conversationId }) => {
const {
services: { application },
} = useKibana();
const { navigateToWorkchatUrl } = 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};
`;
const currentUser = useCurrentUser();
const { agent } = useAgent({ agentId });
const { conversations, refresh: refreshConversations } = useConversationList({ agentId });
const onConversationUpdate = useCallback(
(changes: ConversationEventChanges) => {
if (!conversationId) {
application.navigateToApp('workchat', { path: `/agents/${agentId}/chat/${changes.id}` });
navigateToWorkchatUrl(appPaths.chat.conversation({ agentId, conversationId: changes.id }));
}
refreshConversations();
},
[agentId, application, conversationId, refreshConversations]
[agentId, conversationId, refreshConversations, navigateToWorkchatUrl]
);
const [connectorId, setConnectorId] = useState<string>();
@ -65,17 +62,18 @@ export const WorkchatChatView: React.FC<WorkchatChatViewProps> = ({ agentId, con
grow={false}
panelled={false}
>
<KibanaPageTemplate.Sidebar paddingSize="none">
<ConversationList
<KibanaPageTemplate.Sidebar paddingSize="none" minWidth={280}>
<ConversationPanel
agentId={agentId}
conversations={conversations}
activeConversationId={conversationId}
onConversationSelect={(newConvId) => {
application.navigateToApp('workchat', { path: `/agents/${agentId}/chat/${newConvId}` });
navigateToWorkchatUrl(
appPaths.chat.conversation({ agentId, conversationId: newConvId })
);
}}
onNewConversationSelect={() => {
application.navigateToApp('workchat', {
path: `/agents/${agentId}/chat/${newConversationId}`,
});
navigateToWorkchatUrl(appPaths.chat.new({ agentId }));
}}
/>
</KibanaPageTemplate.Sidebar>
@ -88,7 +86,11 @@ export const WorkchatChatView: React.FC<WorkchatChatViewProps> = ({ agentId, con
justifyContent="center"
responsive={false}
>
<ChatHeader connectorId={connectorId} agent={agent} onConnectorChange={setConnectorId} />
<ChatHeader
connectorId={connectorId}
conversationId={conversationId}
onConnectorChange={setConnectorId}
/>
<Chat
agentId={agentId}
conversationId={conversationId}

View file

@ -0,0 +1,44 @@
/*
* 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 type { AuthenticatedUser } from '@kbn/core/public';
import type { ConversationEvent } from '../../../../../common/conversation_events';
import type { ProgressionEvent } from '../../../../../common/chat_events';
import { getConversationRounds } from '../../../utils/conversation_rounds';
import { WithFadeIn } from '../../utilities/fade_in';
import type { ChatStatus } from '../../../hooks/use_chat';
import { ChatConversationRound } from './conversation_round';
interface ChatConversationProps {
conversationEvents: ConversationEvent[];
progressionEvents: ProgressionEvent[];
chatStatus: ChatStatus;
currentUser: AuthenticatedUser | undefined;
}
export const ChatConversation: React.FC<ChatConversationProps> = ({
conversationEvents,
progressionEvents,
chatStatus,
}) => {
const rounds = useMemo(() => {
return getConversationRounds({ conversationEvents, progressionEvents, chatStatus });
}, [conversationEvents, progressionEvents, chatStatus]);
return (
<>
{rounds.map((round) => {
return (
<WithFadeIn key={round.userMessage.id}>
<ChatConversationRound round={round} />
</WithFadeIn>
);
})}
</>
);
};

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiTimelineItem, EuiLoadingElastic, EuiAvatar } from '@elastic/eui';
import type { ProgressionEvent } from '../../../../../common/chat_events';
import { FancyLoadingText } from '../../utilities/fancy_loading_text';
interface ChatConversationMessageProps {
progressionEvents: ProgressionEvent[];
}
export const ChatConversationProgression: React.FC<ChatConversationMessageProps> = ({
progressionEvents,
}) => {
if (progressionEvents.length === 0) {
return undefined;
}
const getText = (event: ProgressionEvent): string => {
switch (event.data.step) {
case 'planning':
return 'Thinking about which tools to use';
case 'retrieval':
return 'Calling relevant tools';
case 'analysis':
return 'Analysing the results';
case 'generate_summary':
return 'Summarizing content';
default:
return 'Working';
}
};
const lastEvent = progressionEvents[progressionEvents.length - 1];
const icon = <EuiAvatar name="loading" color="plain" iconType={EuiLoadingElastic} />;
return (
<EuiTimelineItem icon={icon}>
<FancyLoadingText text={getText(lastEvent)} />
</EuiTimelineItem>
);
};

View file

@ -109,6 +109,10 @@ const esqlLanguagePlugin = () => {
};
};
/**
* Component handling markdown support to the assistant's responses.
* Also handles "loading" state by appending the blinking cursor.
*/
export function ChatMessageText({ loading, content }: Props) {
const containerClassName = css`
overflow-wrap: anywhere;

View file

@ -0,0 +1,139 @@
/*
* 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, useMemo, useCallback } from 'react';
import { css } from '@emotion/css';
import {
EuiPanel,
EuiText,
useEuiTheme,
EuiTabs,
EuiTab,
EuiTabbedContentTab,
useEuiFontSize,
EuiNotificationBadge,
} from '@elastic/eui';
import type { ConversationRound } from '../../../utils/conversation_rounds';
import { RoundTabAnswer } from './round_tab_answer';
import { RoundTabSources } from './round_tab_sources';
interface ConversationRoundProps {
round: ConversationRound;
}
export const ChatConversationRound: React.FC<ConversationRoundProps> = ({ round }) => {
const { euiTheme } = useEuiTheme();
const { userMessage, loading: isRoundLoading, assistantMessage } = round;
const rootPanelClass = css`
margin-bottom: ${euiTheme.size.xl};
`;
const tabsContainerClass = css`
border-bottom: ${euiTheme.border.thin};
padding: 0 ${euiTheme.size.xxl};
`;
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);
`;
const [selectedTabId, setSelectedTabId] = useState('answer');
const tabs = useMemo(() => {
const tabList: Array<Omit<EuiTabbedContentTab, 'content'>> = [];
// main answer tab, always present
tabList.push({
id: 'answer',
name: 'Answer',
append: undefined,
});
// sources tab - only when we got sources and message is not loading
if (!isRoundLoading && assistantMessage?.citations.length) {
tabList.push({
id: 'sources',
name: 'Sources',
append: (
<EuiNotificationBadge size="m" color="subdued">
{assistantMessage!.citations.length}
</EuiNotificationBadge>
),
});
}
return tabList;
}, [isRoundLoading, assistantMessage]);
const onSourceClick = useCallback(() => {
setSelectedTabId('sources');
}, [setSelectedTabId]);
const tabContents = useMemo(() => {
return {
answer: (
<RoundTabAnswer
key={`round-${round.userMessage.id}-answer-tab`}
round={round}
onSourceClick={onSourceClick}
/>
),
sources: <RoundTabSources key={`round-${round.userMessage.id}-sources-tab`} round={round} />,
} as Record<string, React.ReactNode>;
}, [round, onSourceClick]);
const selectedTabContent = useMemo(() => {
return tabContents[selectedTabId];
}, [tabContents, selectedTabId]);
const onSelectedTabChanged = (id: string) => {
setSelectedTabId(id);
};
return (
<EuiPanel
className={rootPanelClass}
borderRadius="none"
paddingSize="none"
hasShadow={false}
hasBorder={true}
>
<div className={userTextContainerClass}>
<EuiText color="subdued" size="m" className={userMessageTextClass}>
{userMessage.content}
</EuiText>
</div>
<div className={tabsContainerClass}>
<EuiTabs bottomBorder={false}>
{tabs.map((tab, index) => (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
append={tab.append}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</div>
<div className={tabContentPanelClass}>{selectedTabContent}</div>
</EuiPanel>
);
};

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 React from 'react';
import { ContentRef } from '@kbn/wci-common';
import { css } from '@emotion/css';
import {
EuiPanel,
EuiText,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiSpacer,
EuiFlexGrid,
EuiHorizontalRule,
EuiIcon,
EuiTextColor,
useIsWithinBreakpoints,
} from '@elastic/eui';
import type { ConversationRound } from '../../../utils/conversation_rounds';
import { ChatMessageText } from './chat_message_text';
import { ChatConversationProgression } from './chat_conversation_progression';
interface RoundTabAnswerProps {
round: ConversationRound;
onSourceClick: (ref: ContentRef) => void;
}
export const RoundTabAnswer: React.FC<RoundTabAnswerProps> = ({ round }) => {
const { euiTheme } = useEuiTheme();
const isMobile = useIsWithinBreakpoints(['xs', 's']);
const { assistantMessage, progressionEvents, loading } = round;
const showSources = !loading && (assistantMessage?.citations.length ?? 0) > 0;
const subTitlesClass = css`
font-weight: ${euiTheme.font.weight.bold};
`;
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<ChatConversationProgression progressionEvents={progressionEvents} />
<ChatMessageText
content={assistantMessage?.content ?? ''}
loading={loading && progressionEvents.length === 0}
/>
</EuiFlexItem>
{showSources && (
<EuiFlexItem>
<EuiHorizontalRule />
<EuiText size="xs" className={subTitlesClass}>
Sources - Most relevant
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGrid direction="row" columns={isMobile ? 2 : 3}>
{assistantMessage!.citations.slice(0, 3).map((ref) => (
<EuiFlexItem>
<SourceSummary key={`${ref.sourceId}-${ref.contentId}`} contentRef={ref} />
</EuiFlexItem>
))}
</EuiFlexGrid>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
interface SourceSummaryProps {
contentRef: ContentRef;
}
const SourceSummary: React.FC<SourceSummaryProps> = ({ contentRef }) => {
const { euiTheme } = useEuiTheme();
const panelClass = css`
cursor: pointer;
border: ${euiTheme.border.thin};
&:hover {
border-color: ${euiTheme.colors.borderBaseFormsControl};
}
`;
return (
<EuiPanel className={panelClass} hasShadow={false} hasBorder={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup
direction="row"
gutterSize="s"
justifyContent="flexStart"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiIcon type="database" color="primary" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">
<EuiTextColor color={euiTheme.colors.link}>{contentRef.sourceId}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">{contentRef.contentId}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,85 @@
/*
* 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 { ContentRef } from '@kbn/wci-common';
import {
EuiPanel,
EuiText,
EuiTextColor,
EuiIcon,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
} from '@elastic/eui';
import type { ConversationRound } from '../../../utils/conversation_rounds';
interface RoundTabSourcesProps {
round: ConversationRound;
}
export const RoundTabSources: React.FC<RoundTabSourcesProps> = ({ round }) => {
const { assistantMessage } = round;
if (!assistantMessage) {
return undefined;
}
const references = assistantMessage.citations;
return (
<EuiFlexGroup direction="column" gutterSize="m">
{references.map((ref) => (
<EuiFlexItem>
<SourceSummary key={`${ref.sourceId}-${ref.contentId}`} contentRef={ref} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
interface SourceSummaryProps {
contentRef: ContentRef;
}
const SourceSummary: React.FC<SourceSummaryProps> = ({ contentRef }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup
direction="row"
gutterSize="s"
justifyContent="flexStart"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiIcon type="database" color="primary" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<EuiTextColor color={euiTheme.colors.link}>{contentRef.sourceId}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="m">{contentRef.contentId}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -1,150 +0,0 @@
/*
* 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, useMemo, type MouseEvent } from 'react';
import { css } from '@emotion/css';
import {
EuiText,
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiListGroup,
EuiListGroupItem,
EuiButton,
useEuiTheme,
euiScrollBarStyles,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ConversationSummary } from '../../../../common/conversations';
import { sortAndGroupConversations } from '../../utils/sort_and_group_conversations';
interface ConversationListProps {
conversations: ConversationSummary[];
activeConversationId?: string;
onConversationSelect?: (conversationId: string) => void;
onNewConversationSelect?: () => void;
}
const scrollContainerClassName = (scrollBarStyles: string) => css`
overflow-y: auto;
${scrollBarStyles}
`;
const fullHeightClassName = css`
height: 100%;
`;
const containerClassName = css`
height: 100%;
width: 100%;
`;
const pageSectionContentClassName = css`
width: 100%;
display: flex;
flex-grow: 1;
height: 100%;
max-block-size: calc(100vh - var(--kbnAppHeadersOffset, var(--euiFixedHeadersOffset, 0)));
`;
export const ConversationList: React.FC<ConversationListProps> = ({
conversations,
activeConversationId,
onConversationSelect,
onNewConversationSelect,
}) => {
const handleConversationClick = useCallback(
(e: MouseEvent<HTMLButtonElement> | MouseEvent<HTMLAnchorElement>, conversationId: string) => {
if (onConversationSelect) {
e.preventDefault();
onConversationSelect(conversationId);
}
},
[onConversationSelect]
);
const theme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(theme);
const titleClassName = css`
text-transform: uppercase;
font-weight: ${theme.euiTheme.font.weight.bold};
`;
const conversationGroups = useMemo(() => {
return sortAndGroupConversations(conversations);
}, [conversations]);
return (
<EuiPanel
paddingSize="m"
hasShadow={false}
color="transparent"
className={pageSectionContentClassName}
>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText size="s" className={titleClassName}>
{i18n.translate('xpack.workchatApp.conversationList.conversationTitle', {
defaultMessage: 'Conversations',
})}
</EuiText>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow className={scrollContainerClassName(scrollBarStyles)}>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
{conversationGroups.map(({ conversations: groupConversations, dateLabel }) => {
return (
<EuiFlexItem grow={false} key={dateLabel}>
<EuiPanel hasBorder={false} hasShadow={false} color="transparent" paddingSize="s">
<EuiText size="s">
<h4>{dateLabel}</h4>
</EuiText>
</EuiPanel>
<EuiListGroup flush={false} gutterSize="none" className={fullHeightClassName}>
{groupConversations.map((conversation) => (
<EuiListGroupItem
key={conversation.id}
onClick={(event) => handleConversationClick(event, conversation.id)}
label={conversation.title}
size="s"
isActive={conversation.id === activeConversationId}
showToolTip
/>
))}
</EuiListGroup>
<EuiSpacer size="s" />
</EuiFlexItem>
);
})}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="newChat"
onClick={() => onNewConversationSelect && onNewConversationSelect()}
>
{i18n.translate('xpack.workchatApp.newConversationButtonLabel', {
defaultMessage: 'New conversation',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiHealth,
EuiAvatar,
EuiSkeletonText,
} from '@elastic/eui';
import { useAgent } from '../../../hooks/use_agent';
import { chatCommonLabels } from '../i18n';
interface AssistantBlockProps {
agentId: string;
}
export const AssistantBlock: React.FC<AssistantBlockProps> = ({ agentId }) => {
const { agent } = useAgent({ agentId });
const { euiTheme } = useEuiTheme();
const agentNameClassName = css`
font-weight: ${euiTheme.font.weight.semiBold};
`;
return (
<EuiFlexGroup direction="row" alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiAvatar name={agent?.name ?? 'Assistant'} size="l" type="user" />
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFlexGroup
direction="column"
gutterSize="xs"
alignItems="flexStart"
justifyContent="center"
>
<EuiFlexItem grow={false}>
<EuiSkeletonText lines={1} size="s" isLoading={agent === undefined}>
<EuiText size="s" className={agentNameClassName}>
{agent?.name}
</EuiText>
</EuiSkeletonText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="success">
<EuiText size="xs">{chatCommonLabels.assistantStatus.healthy}</EuiText>
</EuiHealth>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,81 @@
/*
* 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,
EuiFlexItem,
EuiListGroup,
EuiListGroupItem,
useEuiTheme,
useEuiFontSize,
} from '@elastic/eui';
import type { ConversationSummary } from '../../../../../common/conversations';
interface ConversationGroupProps {
conversations: ConversationSummary[];
dateLabel: string;
activeConversationId?: string;
onConversationClick: (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
conversationId: string
) => void;
}
const fullHeightClassName = css`
height: 100%;
`;
const groupLabelClassName = css`
text-transform: uppercase;
`;
export const ConversationGroup: React.FC<ConversationGroupProps> = ({
conversations,
dateLabel,
activeConversationId,
onConversationClick,
}) => {
const { euiTheme } = useEuiTheme();
const listItemClassName = css`
font-size: calc(${useEuiFontSize('s').fontSize} - 1px);
font-weight: ${euiTheme.font.weight.regular};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
return (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={false} hasShadow={false} color="transparent" paddingSize="s">
<EuiText size="s" className={groupLabelClassName}>
<h4>{dateLabel}</h4>
</EuiText>
</EuiPanel>
<EuiListGroup flush={false} gutterSize="none" className={fullHeightClassName}>
{conversations.map((conversation) => (
<EuiListGroupItem
key={conversation.id}
color="text"
label={
<EuiText size="s" className={listItemClassName}>
{conversation.title}
</EuiText>
}
onClick={(event) => onConversationClick(event, conversation.id)}
size="s"
isActive={conversation.id === activeConversationId}
showToolTip
/>
))}
</EuiListGroup>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,165 @@
/*
* 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, useMemo } from 'react';
import { css } from '@emotion/css';
import {
EuiText,
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
useEuiTheme,
euiScrollBarStyles,
EuiSpacer,
EuiIcon,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ConversationSummary } from '../../../../../common/conversations';
import { sortAndGroupConversations } from '../../../utils/sort_and_group_conversations';
import { AssistantBlock } from './assistant_block';
import { ConversationGroup } from './conversation_group';
interface ConversationPanelProps {
agentId: string;
conversations: ConversationSummary[];
activeConversationId?: string;
onConversationSelect?: (conversationId: string) => void;
onNewConversationSelect?: () => void;
}
export const ConversationPanel: React.FC<ConversationPanelProps> = ({
agentId,
conversations,
activeConversationId,
onConversationSelect,
onNewConversationSelect,
}) => {
const handleConversationClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>, conversationId: string) => {
if (onConversationSelect) {
event.preventDefault();
onConversationSelect(conversationId);
}
},
[onConversationSelect]
);
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 scrollContainerClassName = css`
overflow-y: auto;
padding: 0 ${theme.euiTheme.size.base};
${scrollBarStyles}
`;
const createButtonRuleClassName = css`
margin-bottom: ${theme.euiTheme.size.base};
`;
const conversationGroups = useMemo(() => {
return sortAndGroupConversations(conversations);
}, [conversations]);
return (
<EuiPanel
paddingSize="m"
hasShadow={false}
color="transparent"
className={pageSectionContentClassName}
>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false} className={sectionBlockPaddingCLassName}>
<AssistantBlock agentId={agentId} />
<EuiSpacer size="l" />
</EuiFlexItem>
<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}>
{i18n.translate('xpack.workchatApp.conversationList.conversationTitle', {
defaultMessage: 'Conversations',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem grow className={scrollContainerClassName}>
<EuiFlexGroup
direction="column"
className={containerClassName}
gutterSize="none"
responsive={false}
>
{conversationGroups.map(({ conversations: groupConversations, dateLabel }, index) => (
<>
<ConversationGroup
key={dateLabel}
conversations={groupConversations}
dateLabel={dateLabel}
activeConversationId={activeConversationId}
onConversationClick={handleConversationClick}
/>
{index < conversationGroups.length - 1 && (
<EuiSpacer key={dateLabel + '-spacer'} size="m" />
)}
</>
))}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule size="full" margin="none" className={createButtonRuleClassName} />
</EuiFlexItem>
<EuiFlexItem grow={false} className={sectionBlockPaddingCLassName}>
<EuiButton
iconType="newChat"
onClick={() => onNewConversationSelect && onNewConversationSelect()}
>
{i18n.translate('xpack.workchatApp.newConversationButtonLabel', {
defaultMessage: 'New conversation',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,77 @@
/*
* 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';
import { ChatHeaderSettingsPanel } from './chat_header_settings_panel';
interface ChatHeaderProps {
conversationId: string | undefined;
connectorId: string | undefined;
onConnectorChange: (connectorId: string) => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
conversationId,
connectorId,
onConnectorChange,
}) => {
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.newConversationLabel}
</h3>
</EuiSkeletonTitle>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ChatHeaderSettingsPanel
connectorId={connectorId}
onConnectorChange={onConnectorChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,88 @@
/*
* 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, useEffect, useMemo } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { EuiButtonIcon, EuiContextMenu, EuiPopover, useGeneratedHtmlId } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useConnectors } from '../../../hooks/use_connectors';
interface ChatHeaderSettingsPanel {
connectorId: string | undefined;
onConnectorChange: (connectorId: string) => void;
}
export const ChatHeaderSettingsPanel: React.FC<ChatHeaderSettingsPanel> = ({
connectorId,
onConnectorChange,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const { connectors } = useConnectors();
const contextMenuPopoverId = useGeneratedHtmlId({ prefix: 'menuPopover' });
const [lastSelectedConnectorId, setLastSelectedConnectorId] = useLocalStorage<string>(
'workchat.lastSelectedConnectorId'
);
useEffect(() => {
if (connectors.length && !connectorId) {
onConnectorChange(lastSelectedConnectorId ?? connectors[0].connectorId);
}
}, [connectorId, connectors, onConnectorChange, lastSelectedConnectorId]);
const onButtonClick = () => {
setPopover(!isPopoverOpen);
};
const closePopover = () => {
setPopover(false);
};
const button = (
<EuiButtonIcon
color="text"
display="base"
size="m"
iconType="controlsHorizontal"
iconSize="m"
onClick={onButtonClick}
/>
);
const panels = useMemo(() => {
return [
{
id: 0,
title: i18n.translate('workchatApp.chat.headerBar.connectorList.connectorsLabel', {
defaultMessage: 'Connectors',
}),
items: connectors.map((connector) => {
return {
name: connector.name,
icon: connector.connectorId === connectorId ? 'check' : 'empty',
onClick: () => {
onConnectorChange(connector.connectorId);
setLastSelectedConnectorId(connector.connectorId);
},
};
}),
},
];
}, [connectors, connectorId, onConnectorChange, setLastSelectedConnectorId]);
return (
<EuiPopover
id={contextMenuPopoverId}
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downRight"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
);
};

View file

@ -0,0 +1,29 @@
/*
* 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 = {
newConversationLabel: i18n.translate('workchatApp.chat.conversations.newConversationLabel', {
defaultMessage: 'New conversation',
}),
userInputBox: {
placeholder: i18n.translate('xpack.workchatApp.chatInputForm.placeholder', {
defaultMessage: 'Ask anything',
}),
},
assistant: {
defaultNameLabel: i18n.translate('xpack.workchatApp.assistant.defaultNameLabel', {
defaultMessage: 'Assistant',
}),
},
assistantStatus: {
healthy: i18n.translate('workchatApp.chat.assistantStatus.healthy', {
defaultMessage: 'Healthy',
}),
},
};

View file

@ -0,0 +1,33 @@
/*
* 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, { ReactNode } from 'react';
import { css, keyframes } from '@emotion/react';
import { euiCanAnimate } from '@elastic/eui';
const fadeIn = keyframes`
from {
opacity: 0.6;
}
to {
opacity: 1;
}
`;
const fadeInStyle = css`
${euiCanAnimate} {
animation: ${fadeIn} 0.6s ease-in forwards;
}
`;
interface WithFadeInProps {
children: ReactNode;
}
export const WithFadeIn: React.FC<WithFadeInProps> = ({ children }) => {
return <div css={fadeInStyle}>{children}</div>;
};

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, keyframes } from '@emotion/react';
const gradientAnimation = keyframes`
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
`;
const dotsAnimation = keyframes`
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
100% { content: ""; }
`;
const fancyTextStyle = css`
background: linear-gradient(270deg, #ec4899, #8b5cf6, #3b82f6);
background-size: 200% 200%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: ${gradientAnimation} 3s ease infinite;
font-weight: bold;
display: inline-block;
`;
const dotsStyle = css`
&::after {
content: '';
animation: ${dotsAnimation} 1.2s steps(4, end) infinite;
display: inline-block;
white-space: pre;
}
`;
interface FancyLoadingTextProps {
text: string;
className?: string;
}
export const FancyLoadingText = ({ text, className = '' }: FancyLoadingTextProps) => {
return (
<span css={[fancyTextStyle, dotsStyle]} className={className}>
{text}
</span>
);
};

View file

@ -110,15 +110,15 @@ export const useChat = ({
}
},
complete: () => {
setConversationEvents((prevEvents) => [...prevEvents, ...streamMessages]);
setPendingMessages([]);
setProgressionEvents([]);
setConversationEvents((prevEvents) => [...prevEvents, ...streamMessages]);
setStatus('ready');
},
error: (err) => {
setConversationEvents((prevEvents) => [...prevEvents, ...streamMessages]);
setPendingMessages([]);
setProgressionEvents([]);
setConversationEvents((prevEvents) => [...prevEvents, ...streamMessages]);
setStatus('error');
onError?.(err);
@ -144,7 +144,9 @@ export const useChat = ({
);
const setConversationEventsExternal = useCallback((newEvents: ConversationEvent[]) => {
// TODO: unsub from observable + set status ready
setConversationEvents(newEvents);
setProgressionEvents([]);
setPendingMessages([]);
}, []);

View file

@ -5,54 +5,22 @@
* 2.0.
*/
import { useCallback } from 'react';
import { useAbortableAsync } from '@kbn/react-hooks';
import type { ConversationEventChanges } from '../../../common/chat_events';
import { useChat } from './use_chat';
import { useQuery } from '@tanstack/react-query';
import { useWorkChatServices } from './use_workchat_service';
import { queryKeys } from '../query_keys';
export const useConversation = ({
agentId,
conversationId,
connectorId,
onConversationUpdate,
}: {
agentId: string;
conversationId: string | undefined;
connectorId?: string;
onConversationUpdate: (update: ConversationEventChanges) => void;
}) => {
export const useConversation = ({ conversationId }: { conversationId: string | undefined }) => {
const { conversationService } = useWorkChatServices();
const onConversationUpdateInternal = useCallback(
(update: ConversationEventChanges) => {
onConversationUpdate(update);
const { data: conversation, isLoading } = useQuery({
queryKey: queryKeys.conversations.byId(conversationId ?? 'new'),
queryFn: async () => {
if (conversationId) {
return conversationService.get(conversationId);
}
return undefined;
},
[onConversationUpdate]
);
const {
conversationEvents,
progressionEvents,
setConversationEvents,
sendMessage,
status: chatStatus,
} = useChat({
agentId,
conversationId,
connectorId,
onConversationUpdate: onConversationUpdateInternal,
});
useAbortableAsync(async () => {
// TODO: better conv state management - only has events atm
if (conversationId) {
const conversation = await conversationService.get(conversationId);
setConversationEvents(conversation.events);
} else {
setConversationEvents([]);
}
}, [conversationId, conversationService]);
return { conversationEvents, progressionEvents, chatStatus, sendMessage };
return { conversation, isLoading };
};

View file

@ -1,48 +0,0 @@
/*
* 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 { parseToolName } from '@kbn/wci-common';
import { IntegrationToolComponentProps } from '@kbn/wci-browser';
import { useWorkChatServices } from './use_workchat_service';
import { useIntegrationList } from './use_integration_list';
import { ChatDefaultToolCallRendered } from '../components/chat/chat_default_tool_call';
type WiredToolComponentProps = Omit<IntegrationToolComponentProps, 'integration'>;
/**
* Hook to get the component that should be used to render a tool call in the conversation history
*/
export const useIntegrationToolView = (
fullToolName: string
): React.ComponentType<WiredToolComponentProps> => {
const { integrationRegistry } = useWorkChatServices();
const { integrationId, toolName } = useMemo(() => parseToolName(fullToolName), [fullToolName]);
const { integrations } = useIntegrationList();
const integration = useMemo(() => {
return integrations.find((integ) => integ.id === integrationId);
}, [integrationId, integrations]);
const ToolRenderedComponent = useMemo(() => {
if (integration) {
const definition = integrationRegistry.get(integration.type);
if (definition) {
return definition.getToolCallComponent(toolName);
}
}
}, [integrationRegistry, integration, toolName]);
return useMemo(() => {
if (integration && ToolRenderedComponent) {
return (props: WiredToolComponentProps) => (
<ToolRenderedComponent integration={integration} {...props} />
);
}
return ChatDefaultToolCallRendered;
}, [ToolRenderedComponent, integration]);
};

View file

@ -12,6 +12,7 @@ export const queryKeys = {
conversations: {
all: ['conversations'] as const,
byAgent: (agentId: string) => ['conversations', 'list', { agentId }],
byId: (conversationId: string) => ['conversations', conversationId],
},
agents: {
all: ['agents'] as const,

View file

@ -1,126 +0,0 @@
/*
* 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 type { AssistantMessage, UserMessage, ToolCall } from '../../../common/conversation_events';
import type { ProgressionEvent } from '../../../common/chat_events';
interface ConversationItemBase {
id: string;
loading: boolean;
}
export type UserMessageConversationItem = ConversationItemBase & {
type: 'user_message';
message: UserMessage;
};
export type AssistantMessageConversationItem = ConversationItemBase & {
type: 'assistant_message';
message: AssistantMessage;
};
export type ToolCallConversationItem = ConversationItemBase & {
type: 'tool_call';
messageId: string;
toolCall: ToolCall;
toolResult?: string;
};
export type ProgressionConversationItem = ConversationItemBase & {
type: 'progression';
progressionEvents: ProgressionEvent[];
};
/**
* UI-specific representation of the conversation events.
*/
export type ConversationItem =
| UserMessageConversationItem
| AssistantMessageConversationItem
| ToolCallConversationItem
| ProgressionConversationItem;
export const isUserMessageItem = (item: ConversationItem): item is UserMessageConversationItem => {
return item.type === 'user_message';
};
export const isAssistantMessageItem = (
item: ConversationItem
): item is AssistantMessageConversationItem => {
return item.type === 'assistant_message';
};
export const isToolCallItem = (item: ConversationItem): item is ToolCallConversationItem => {
return item.type === 'tool_call';
};
export const isProgressionItem = (item: ConversationItem): item is ProgressionConversationItem => {
return item.type === 'progression';
};
export const createProgressionItem = ({
progressionEvents,
loading = false,
}: {
progressionEvents: ProgressionEvent[];
loading?: boolean;
}): ProgressionConversationItem => {
return {
id: 'foobar', // TODO don't have an ID now, but that whole thing will get refactored soon
type: 'progression',
progressionEvents,
loading,
};
};
export const createUserMessageItem = ({
message,
loading = false,
}: {
message: UserMessage;
loading?: boolean;
}): UserMessageConversationItem => {
return {
id: message.id,
type: 'user_message',
message,
loading,
};
};
export const createAssistantMessageItem = ({
message,
loading = false,
}: {
message: AssistantMessage;
loading?: boolean;
}): AssistantMessageConversationItem => {
return {
id: message.id,
type: 'assistant_message',
message,
loading,
};
};
export const createToolCallItem = ({
messageId,
toolCall,
loading = false,
}: {
messageId: string;
toolCall: ToolCall;
loading?: boolean;
}): ToolCallConversationItem => {
return {
id: toolCall.toolCallId,
type: 'tool_call',
messageId,
toolCall,
loading,
};
};

View file

@ -0,0 +1,100 @@
/*
* 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 {
type ConversationEvent,
isAssistantMessage,
isUserMessage,
isToolResult,
type ToolCall,
type UserMessage,
type AssistantMessage,
} from '../../../common/conversation_events';
import type { ProgressionEvent } from '../../../common/chat_events';
import type { ChatStatus } from '../hooks/use_chat';
export interface ConversationRoundToolCall {
toolCall: ToolCall;
toolResult?: string;
}
export interface ConversationRound {
userMessage: UserMessage;
assistantMessage?: AssistantMessage;
toolCalls: ConversationRoundToolCall[];
progressionEvents: ProgressionEvent[];
loading: boolean;
}
export const getConversationRounds = ({
conversationEvents,
progressionEvents,
chatStatus,
}: {
conversationEvents: ConversationEvent[];
progressionEvents: ProgressionEvent[];
chatStatus: ChatStatus;
}): ConversationRound[] => {
const toolCallMap = new Map<string, ConversationRoundToolCall>();
const rounds: ConversationRound[] = [];
let current: Partial<ConversationRound> | undefined;
conversationEvents.forEach((item) => {
if (isUserMessage(item)) {
if (current?.userMessage) {
throw new Error('chained user message');
}
if (!current) {
current = {
toolCalls: [],
progressionEvents: [],
};
}
current.userMessage = item;
}
if (isToolResult(item)) {
const toolCallItem = toolCallMap.get(item.toolCallId);
if (toolCallItem) {
toolCallItem.toolResult = item.toolResult;
}
}
if (isAssistantMessage(item)) {
if (item.toolCalls.length) {
item.toolCalls.forEach((toolCall) => {
const roundToolCall = {
toolCall,
};
current!.toolCalls!.push(roundToolCall);
toolCallMap.set(toolCall.toolCallId, roundToolCall);
});
} else {
current!.assistantMessage = item;
rounds.push(current as ConversationRound);
current = undefined;
}
}
});
if (current) {
rounds.push(current as ConversationRound);
}
if (rounds.length > 0) {
const lastRound = rounds[rounds.length - 1];
if (progressionEvents) {
lastRound.progressionEvents = progressionEvents;
}
if (chatStatus === 'loading') {
lastRound.loading = true;
}
}
return rounds;
};

View file

@ -1,91 +0,0 @@
/*
* 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 {
type ConversationEvent,
isAssistantMessage,
isUserMessage,
isToolResult,
createAssistantMessage,
} from '../../../common/conversation_events';
import type { ProgressionEvent } from '../../../common/chat_events';
import type { ChatStatus } from '../hooks/use_chat';
import {
type ConversationItem,
type ToolCallConversationItem,
createUserMessageItem,
createAssistantMessageItem,
createToolCallItem,
createProgressionItem,
isAssistantMessageItem,
isToolCallItem,
isProgressionItem,
} from './conversation_items';
/**
* Utility function preparing the data to display the chat conversation
*/
export const getChartConversationItems = ({
conversationEvents,
progressionEvents,
chatStatus,
}: {
conversationEvents: ConversationEvent[];
progressionEvents: ProgressionEvent[];
chatStatus: ChatStatus;
}): ConversationItem[] => {
const toolCallMap = new Map<string, ToolCallConversationItem>();
const items = conversationEvents.reduce<ConversationItem[]>((list, item) => {
if (isUserMessage(item)) {
list.push(createUserMessageItem({ message: item }));
}
if (isAssistantMessage(item)) {
if (item.content) {
list.push(createAssistantMessageItem({ message: item }));
}
for (const toolCall of item.toolCalls) {
const toolCallItem = createToolCallItem({ messageId: item.id, toolCall });
toolCallMap.set(toolCallItem.toolCall.toolCallId, toolCallItem);
list.push(toolCallItem);
}
}
if (isToolResult(item)) {
const toolCallItem = toolCallMap.get(item.toolCallId);
if (toolCallItem) {
toolCallItem.toolResult = item.toolResult;
}
}
return list;
}, []);
if (progressionEvents.length > 0 && chatStatus === 'loading') {
items.push(createProgressionItem({ progressionEvents }));
}
if (chatStatus === 'loading') {
const lastItem = items[items.length - 1];
if (isAssistantMessageItem(lastItem) || isProgressionItem(lastItem)) {
lastItem.loading = true;
} else if (isToolCallItem(lastItem) && !lastItem.toolResult) {
lastItem.loading = true;
} else {
// need to insert loading placeholder
items.push(
createAssistantMessageItem({
message: createAssistantMessage({ content: '' }),
loading: true,
})
);
}
}
return items;
};

View file

@ -0,0 +1,99 @@
/*
* 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 { sortAndGroupConversations, getDefaultBuckets } from './sort_and_group_conversations';
describe('sortAndGroupConversations', () => {
const mockConversation = (lastUpdated: string) => ({
id: 'test-id',
title: 'Test Conversation',
lastUpdated,
messages: [],
agentId: 'test-agent',
});
it('should return empty array when no conversations are provided', () => {
const result = sortAndGroupConversations([]);
expect(result).toEqual([]);
});
it('should group conversations into correct time buckets', () => {
// Set now to March 20, 2024 12:00:00 UTC
const now = new Date('2024-03-20T12:00:00Z');
const conversations = [
mockConversation('2024-03-20T10:00:00Z'), // Today (after now/d)
mockConversation('2024-03-19T10:00:00Z'), // Yesterday (after now-1d/d)
mockConversation('2024-03-10T10:00:00Z'), // Within 2 weeks (after now/2w)
mockConversation('2024-02-20T10:00:00Z'), // Within 2 weeks (after now/2w)
];
const result = sortAndGroupConversations(conversations, getDefaultBuckets(), now);
expect(result).toHaveLength(3);
expect(result[0].dateLabel).toBe('Today');
expect(result[0].conversations).toHaveLength(1);
expect(result[1].dateLabel).toBe('Yesterday');
expect(result[1].conversations).toHaveLength(1);
expect(result[2].dateLabel).toBe('Last 2 weeks');
expect(result[2].conversations).toHaveLength(2);
});
it('should sort conversations within each group by date (newest first)', () => {
const now = new Date('2024-03-20T12:00:00Z');
const conversations = [
mockConversation('2024-03-20T08:00:00Z'),
mockConversation('2024-03-20T10:00:00Z'),
mockConversation('2024-03-20T09:00:00Z'),
];
const result = sortAndGroupConversations(conversations, getDefaultBuckets(), now);
expect(result[0].conversations).toHaveLength(3);
expect(result[0].conversations[0].lastUpdated).toBe('2024-03-20T10:00:00Z');
expect(result[0].conversations[1].lastUpdated).toBe('2024-03-20T09:00:00Z');
expect(result[0].conversations[2].lastUpdated).toBe('2024-03-20T08:00:00Z');
});
it('should filter out empty groups', () => {
const now = new Date('2024-03-20T12:00:00Z');
const conversations = [
mockConversation('2024-03-20T10:00:00Z'), // Today
mockConversation('2024-02-20T10:00:00Z'), // Within 2 weeks
];
const result = sortAndGroupConversations(conversations, getDefaultBuckets(), now);
expect(result).toHaveLength(2);
expect(result[0].dateLabel).toBe('Today');
expect(result[1].dateLabel).toBe('Last 2 weeks');
});
it('should work with custom bucket definitions', () => {
const now = new Date('2024-03-20T12:00:00Z');
const customBuckets = [
{
code: 'RECENT',
label: 'Recent',
limit: 'now-1h',
},
{
code: 'OLDER',
label: 'Older',
limit: false as const,
},
];
const conversations = [
mockConversation('2024-03-20T11:30:00Z'), // Recent (within last hour)
mockConversation('2024-03-20T10:00:00Z'), // Older (more than an hour ago)
];
const result = sortAndGroupConversations(conversations, customBuckets, now);
expect(result).toHaveLength(2);
expect(result[0].dateLabel).toBe('Recent');
expect(result[0].conversations).toHaveLength(1);
expect(result[1].dateLabel).toBe('Older');
expect(result[1].conversations).toHaveLength(1);
});
});

View file

@ -19,7 +19,13 @@ type ConversationGroupWithDate = ConversationGroup & {
dateLimit: number;
};
const getGroups = () => {
interface ConversationBucketDefinition {
code: string;
label: string;
limit: string | false;
}
export const getDefaultBuckets = (): ConversationBucketDefinition[] => {
return [
{
code: 'TODAY',
@ -36,18 +42,53 @@ const getGroups = () => {
limit: 'now-1d/d',
},
{
code: 'THIS_WEEK',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.thisWeek', {
defaultMessage: 'This week',
code: 'LAST_WEEK',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastWeek', {
defaultMessage: 'Last week',
}),
limit: 'now/w',
},
{
code: 'LAST_2_WEEKS',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastTwoWeeks', {
defaultMessage: 'Last 2 weeks',
}),
limit: 'now/2w',
},
{
code: 'LAST_MONTH',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastMonth', {
defaultMessage: 'Last month',
}),
limit: 'now/m',
},
{
code: 'LAST_3_MONTHS',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastThreeMonths', {
defaultMessage: 'Last 3 months',
}),
limit: 'now/3m',
},
{
code: 'LAST_6_MONTHS',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastSixMonths', {
defaultMessage: 'Last 6 months',
}),
limit: 'now/6m',
},
{
code: 'LAST_YEAR',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.lastYear', {
defaultMessage: 'Last month',
}),
limit: 'now/y',
},
{
code: 'OLDER',
label: i18n.translate('xpack.workchatApp.conversationGroups.labels.before', {
defaultMessage: 'Before',
}),
limit: '',
limit: false,
},
];
};
@ -56,15 +97,18 @@ const getGroups = () => {
* Sort and group conversation by time period to display them in the ConversationList component.
*/
export const sortAndGroupConversations = (
conversations: ConversationSummary[]
conversations: ConversationSummary[],
buckets: ConversationBucketDefinition[] = getDefaultBuckets(),
now: Date = new Date()
): ConversationGroup[] => {
const now = new Date();
const getEpochLimit = (range: string) => {
const getEpochLimit = (range: string | false) => {
if (range === false) {
return 0;
}
return getAbsoluteTime(range, { forceNow: now })?.valueOf() ?? 0;
};
const groups = getGroups().map(({ label, limit }) => {
const groups = buckets.map(({ label, limit }) => {
return emptyGroup(label, getEpochLimit(limit));
});

View file

@ -6,4 +6,4 @@
*/
export { listClientsTools } from './list_clients_tools';
export { getLCTools, toLangchainTool } from './to_langchain_tool';
export { toLangchainTool } from './to_langchain_tool';

View file

@ -12,19 +12,6 @@ import { jsonSchemaToZod } from '@n8n/json-schema-to-zod';
import { GatewayTool } from '../types';
import { McpGatewaySession } from '../session';
export async function getLCTools({
session,
logger,
}: {
session: McpGatewaySession;
logger: Logger;
}): Promise<StructuredTool[]> {
const tools = await session.listTools();
return tools.map((tool) => {
return toLangchainTool({ tool, logger, session });
});
}
export function toLangchainTool({
tool,
session,

View file

@ -27,9 +27,6 @@
"@kbn/inference-common",
"@kbn/wci-common",
"@kbn/i18n",
"@kbn/user-profile-components",
"@kbn/ai-assistant-icon",
"@kbn/react-hooks",
"@kbn/shared-ux-link-redirect-app",
"@kbn/datemath",
"@kbn/zod",