mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
feat: workchat home screen (#217650)
## Summary Workchat home screen. Used avatars as agent icons, works pretty nice imo conversation history limited to 10 in the right column. <img width="1709" alt="Screenshot 2025-04-09 at 13 24 59" src="https://github.com/user-attachments/assets/8f1fce7d-bace-4cd1-97de-0b0bc9c1b526" /> ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
7e35e92b4b
commit
7951e7bca5
7 changed files with 343 additions and 79 deletions
|
@ -1,70 +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 {
|
||||
EuiBasicTable,
|
||||
EuiBasicTableColumn,
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
EuiButton,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { Agent } from '../../../../common/agents';
|
||||
import { useNavigation } from '../../hooks/use_navigation';
|
||||
import { useAgentList } from '../../hooks/use_agent_list';
|
||||
import { useCapabilities } from '../../hooks/use_capabilities';
|
||||
import { appPaths } from '../../app_paths';
|
||||
|
||||
export const HomeAgentSection: React.FC<{}> = () => {
|
||||
const { createWorkchatUrl, navigateToWorkchatUrl } = useNavigation();
|
||||
const { agents } = useAgentList();
|
||||
const { showManagement } = useCapabilities();
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<Agent>> = [
|
||||
{
|
||||
field: 'name',
|
||||
name: 'Name',
|
||||
render: (value, agent) => {
|
||||
return (
|
||||
<EuiLink href={createWorkchatUrl(appPaths.chat.new({ agentId: agent.id }))}>
|
||||
{value}
|
||||
</EuiLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ field: 'description', name: 'Description' },
|
||||
{ field: 'user.name', name: 'Created by' },
|
||||
];
|
||||
|
||||
return (
|
||||
<KibanaPageTemplate.Section>
|
||||
<EuiTitle size="s">
|
||||
<h4>Your agents</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer />
|
||||
<EuiBasicTable columns={columns} items={agents} />
|
||||
<EuiSpacer />
|
||||
{showManagement && (
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={() => {
|
||||
navigateToWorkchatUrl(appPaths.agents.list);
|
||||
}}
|
||||
>
|
||||
Go to agent management
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</KibanaPageTemplate.Section>
|
||||
);
|
||||
};
|
|
@ -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 {
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiFlexGrid,
|
||||
EuiText,
|
||||
EuiAvatar,
|
||||
EuiPanel,
|
||||
EuiButtonIcon,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useNavigation } from '../../hooks/use_navigation';
|
||||
import { useAgentList } from '../../hooks/use_agent_list';
|
||||
import { appPaths } from '../../app_paths';
|
||||
|
||||
export const HomeAssistantsSection: React.FC<{}> = () => {
|
||||
const { createWorkchatUrl, navigateToWorkchatUrl } = useNavigation();
|
||||
const { agents } = useAgentList();
|
||||
|
||||
const assistantTiles = agents.map((assistant) => {
|
||||
return (
|
||||
<EuiPanel key={assistant.id} paddingSize="m" hasBorder={true}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiAvatar size="l" name={assistant.name} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="gear"
|
||||
color="text"
|
||||
onClick={() => {
|
||||
navigateToWorkchatUrl(appPaths.agents.edit({ agentId: assistant.id }));
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiLink
|
||||
href={createWorkchatUrl(appPaths.chat.new({ agentId: assistant.id }))}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{assistant.name}
|
||||
</EuiLink>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText size="xs" color="subdued">
|
||||
{assistant.description}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiIcon type="users" size="m" />
|
||||
<EuiTitle size="xxs">
|
||||
<h4>
|
||||
{i18n.translate('workchatApp.home.assistants.title', {
|
||||
defaultMessage: 'Assistants',
|
||||
})}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer />
|
||||
<EuiFlexGrid columns={3}>{assistantTiles}</EuiFlexGrid>
|
||||
<EuiSpacer />
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiAvatar,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useNavigation } from '../../hooks/use_navigation';
|
||||
import { useAgentList } from '../../hooks/use_agent_list';
|
||||
import { useConversationList } from '../../hooks/use_conversation_list';
|
||||
import { Agent } from '../../../../common/agents';
|
||||
import { sortAndGroupConversations } from '../../utils/sort_and_group_conversations';
|
||||
import { sliceRecentConversations } from '../../utils/slice_recent_conversations';
|
||||
import { appPaths } from '../../app_paths';
|
||||
|
||||
export const HomeConversationHistorySection: React.FC<{}> = () => {
|
||||
const { navigateToWorkchatUrl } = useNavigation();
|
||||
const { agents } = useAgentList();
|
||||
const { conversations } = useConversationList({});
|
||||
|
||||
const agentMap = useMemo<Record<string, Agent>>(() => {
|
||||
return agents.reduce<Record<string, Agent>>((map, agent) => {
|
||||
map[agent.id] = agent;
|
||||
return map;
|
||||
}, {});
|
||||
}, [agents]);
|
||||
|
||||
const conversationGroups = useMemo(() => {
|
||||
return sortAndGroupConversations(sliceRecentConversations(conversations, 10));
|
||||
}, [conversations]);
|
||||
|
||||
const recentConversations = 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>
|
||||
<EuiFlexGroup direction={'column'}>
|
||||
{groupConversations.map((conversation) => {
|
||||
const agent = agentMap[conversation.agentId];
|
||||
return (
|
||||
<EuiFlexItem key={conversation.id}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
{agent && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiAvatar name={agent.name} size="s" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem direction="column" grow={false}>
|
||||
<EuiLink
|
||||
onClick={() => {
|
||||
navigateToWorkchatUrl(
|
||||
appPaths.chat.conversation({
|
||||
agentId: agent.id,
|
||||
conversationId: conversation.id,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{conversation.title}
|
||||
</EuiLink>
|
||||
<EuiText size="xs" onClick={() => {}}>
|
||||
{agent.name}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={false} style={{ maxWidth: 400 }}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiIcon type="list" size="m" />
|
||||
<EuiTitle size="xxs">
|
||||
<h4>
|
||||
{i18n.translate('workchatApp.home.recentConversations.title', {
|
||||
defaultMessage: 'Recent conversations',
|
||||
})}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
{recentConversations}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -7,16 +7,26 @@
|
|||
|
||||
import React from 'react';
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { HomeAgentSection } from './home_agent_section';
|
||||
import { HomeIntegrationSection } from './home_integration_section';
|
||||
import { EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
|
||||
import { HomeAssistantsSection } from './home_assistants_section';
|
||||
import { HomeConversationHistorySection } from './home_conversation_history';
|
||||
|
||||
const headerButtons = [
|
||||
<EuiButtonEmpty iconType={'questionInCircle'} color="primary" iconSide="left" href="/">
|
||||
Learn more
|
||||
</EuiButtonEmpty>,
|
||||
];
|
||||
|
||||
export const WorkChatHomeView: React.FC<{}> = () => {
|
||||
return (
|
||||
<KibanaPageTemplate panelled data-test-subj="workChatHomePage">
|
||||
<KibanaPageTemplate.Header pageTitle="WorkChat" />
|
||||
|
||||
<HomeAgentSection />
|
||||
<HomeIntegrationSection />
|
||||
<KibanaPageTemplate data-test-subj="workChatHomePage">
|
||||
<KibanaPageTemplate.Header pageTitle="WorkChat" rightSideItems={headerButtons} />
|
||||
<KibanaPageTemplate.Section>
|
||||
<EuiFlexGroup gutterSize="l" alignItems="flexStart">
|
||||
<HomeAssistantsSection />
|
||||
<HomeConversationHistorySection />
|
||||
</EuiFlexGroup>
|
||||
</KibanaPageTemplate.Section>
|
||||
</KibanaPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { queryKeys } from '../query_keys';
|
||||
import { useWorkChatServices } from './use_workchat_service';
|
||||
|
||||
export const useConversationList = ({ agentId }: { agentId: string }) => {
|
||||
export const useConversationList = ({ agentId }: { agentId?: string }) => {
|
||||
const { conversationService } = useWorkChatServices();
|
||||
|
||||
const {
|
||||
|
@ -17,7 +17,7 @@ export const useConversationList = ({ agentId }: { agentId: string }) => {
|
|||
isLoading,
|
||||
refetch: refresh,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.conversations.byAgent(agentId),
|
||||
queryKey: agentId ? queryKeys.conversations.byAgent(agentId) : queryKeys.conversations.all,
|
||||
queryFn: async () => {
|
||||
return conversationService.list({ agentId });
|
||||
},
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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 { sliceRecentConversations } from './slice_recent_conversations';
|
||||
import type { ConversationSummary } from '../../../common/conversations';
|
||||
|
||||
describe('sliceRecentConversations', () => {
|
||||
const createConversation = (id: string, lastUpdated: string): ConversationSummary => ({
|
||||
id,
|
||||
agentId: `agent-${id}`,
|
||||
title: `Conversation ${id}`,
|
||||
lastUpdated,
|
||||
});
|
||||
|
||||
const conversations: ConversationSummary[] = [
|
||||
createConversation('1', '2023-01-01T10:00:00Z'),
|
||||
createConversation('2', '2023-01-02T10:00:00Z'),
|
||||
createConversation('3', '2023-01-03T10:00:00Z'),
|
||||
createConversation('4', '2023-01-04T10:00:00Z'),
|
||||
createConversation('5', '2023-01-05T10:00:00Z'),
|
||||
];
|
||||
|
||||
it('should return the most recent conversations based on the limit', () => {
|
||||
const result = sliceRecentConversations(conversations, 3);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe('5');
|
||||
expect(result[1].id).toBe('4');
|
||||
expect(result[2].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should return all conversations if limit is greater than the number of conversations', () => {
|
||||
const result = sliceRecentConversations(conversations, 10);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].id).toBe('5');
|
||||
expect(result[4].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should return conversations in descending order of lastUpdated date', () => {
|
||||
const unsortedConversations: ConversationSummary[] = [
|
||||
createConversation('3', '2023-01-03T10:00:00Z'),
|
||||
createConversation('1', '2023-01-01T10:00:00Z'),
|
||||
createConversation('5', '2023-01-05T10:00:00Z'),
|
||||
createConversation('2', '2023-01-02T10:00:00Z'),
|
||||
createConversation('4', '2023-01-04T10:00:00Z'),
|
||||
];
|
||||
|
||||
const result = sliceRecentConversations(unsortedConversations, 5);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].id).toBe('5');
|
||||
expect(result[1].id).toBe('4');
|
||||
expect(result[2].id).toBe('3');
|
||||
expect(result[3].id).toBe('2');
|
||||
expect(result[4].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle conversations with same lastUpdated date', () => {
|
||||
const sameTimeConversations: ConversationSummary[] = [
|
||||
createConversation('1', '2023-01-01T10:00:00Z'),
|
||||
createConversation('2', '2023-01-01T10:00:00Z'),
|
||||
createConversation('3', '2023-01-01T10:00:00Z'),
|
||||
];
|
||||
|
||||
const result = sliceRecentConversations(sameTimeConversations, 2);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty array if conversations is empty', () => {
|
||||
const result = sliceRecentConversations([], 3);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if conversations is undefined', () => {
|
||||
const result = sliceRecentConversations(undefined as unknown as ConversationSummary[], 3);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all conversations if limit is undefined', () => {
|
||||
const result = sliceRecentConversations(conversations, undefined as unknown as number);
|
||||
|
||||
expect(result).toEqual(conversations);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 moment from 'moment';
|
||||
import type { ConversationSummary } from '../../../common/conversations';
|
||||
|
||||
/**
|
||||
* Limits the conversations array to the most recent N conversations
|
||||
*/
|
||||
export const sliceRecentConversations = (
|
||||
conversations: ConversationSummary[],
|
||||
limit: number
|
||||
): ConversationSummary[] => {
|
||||
if (!limit || limit <= 0 || !conversations?.length) {
|
||||
return conversations || [];
|
||||
}
|
||||
|
||||
return conversations
|
||||
.map((conversation) => {
|
||||
return {
|
||||
conversation,
|
||||
date: moment(conversation.lastUpdated),
|
||||
};
|
||||
})
|
||||
.sort((conv1, conv2) => {
|
||||
return conv2.date.valueOf() - conv1.date.valueOf();
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(({ conversation }) => conversation);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue