mirror of
https://github.com/elastic/kibana.git
synced 2025-04-19 15:35:00 -04:00
[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:
parent
c2596a1f61
commit
bc1124118c
41 changed files with 1554 additions and 1107 deletions
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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={<></>}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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>;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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([]);
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
@ -6,4 +6,4 @@
|
|||
*/
|
||||
|
||||
export { listClientsTools } from './list_clients_tools';
|
||||
export { getLCTools, toLangchainTool } from './to_langchain_tool';
|
||||
export { toLangchainTool } from './to_langchain_tool';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue