feat: workchat assistant list/details/edit page (#217984)

## Summary

### UI changes
- assistant UI 
  - list view
  - details view
  - modals: edit info, edit prompt, create 
- rename routes from `agents` to `assistatns`

### Server changes
- Add `avatar` object to agent/assistnat saved object schema
- changed schema from dynamic `strict` to `false`

### Recording 



https://github.com/user-attachments/assets/df689d87-2c0e-4e82-8dc1-46de4a9ab9d8



### 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
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [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>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jedr Blaszyk 2025-04-17 15:34:08 +02:00 committed by GitHub
parent 821f74ea5d
commit c5ff7aa155
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1449 additions and 379 deletions

View file

@ -3873,7 +3873,7 @@
}
},
"workchat_agent": {
"dynamic": "strict",
"dynamic": false,
"properties": {
"access_control": {
"properties": {

View file

@ -18,11 +18,15 @@ export interface Agent {
user: UserNameAndId;
public: boolean;
configuration: Record<string, any>;
avatar: {
color?: string;
text?: string;
};
}
export type AgentCreateRequest = Pick<
Agent,
'name' | 'description' | 'configuration' | 'public'
'name' | 'description' | 'configuration' | 'public' | 'avatar'
> & {
id?: string;
};

View file

@ -10,17 +10,16 @@
*/
export const appPaths = {
home: '/',
chat: {
new: ({ agentId }: { agentId: string }) => `/agents/${agentId}/chat`,
new: ({ agentId }: { agentId: string }) => `/assistants/${agentId}/chat`,
conversation: ({ agentId, conversationId }: { agentId: string; conversationId: string }) =>
`/agents/${agentId}/chat/${conversationId}`,
`/assistants/${agentId}/chat/${conversationId}`,
},
agents: {
list: '/agents',
create: '/agents/create',
edit: ({ agentId }: { agentId: string }) => `/agents/${agentId}/edit`,
assistants: {
list: '/assistants',
create: '/assistants/create',
edit: ({ agentId }: { agentId: string }) => `/assistants/${agentId}/edit`,
workflow: ({ agentId }: { agentId: string }) => `/assistants/${agentId}/workflow`,
},
integrations: {

View file

@ -1,174 +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, useCallback } from 'react';
import {
EuiButton,
EuiFieldText,
EuiTextArea,
EuiFlexGroup,
EuiPanel,
EuiFlexItem,
EuiSpacer,
EuiForm,
EuiFormRow,
EuiSelect,
EuiDescribedFormGroup,
} from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useNavigation } from '../../../hooks/use_navigation';
import { useKibana } from '../../../hooks/use_kibana';
import { useBreadcrumb } from '../../../hooks/use_breadcrumbs';
import { useAgentEdition } from '../../../hooks/use_agent_edition';
import { appPaths } from '../../../app_paths';
import { agentLabels } from '../i18n';
interface AgentEditViewProps {
agentId: string | undefined;
}
export const AgentEditView: React.FC<AgentEditViewProps> = ({ agentId }) => {
const { navigateToWorkchatUrl, createWorkchatUrl } = useNavigation();
const {
services: { notifications },
} = useKibana();
const breadcrumb = useMemo(() => {
return [
{ text: agentLabels.breadcrumb.agentsPill, href: createWorkchatUrl(appPaths.agents.list) },
agentId
? { text: agentLabels.breadcrumb.editAgentPill }
: { text: agentLabels.breadcrumb.createAgensPill },
];
}, [agentId, createWorkchatUrl]);
useBreadcrumb(breadcrumb);
const handleCancel = useCallback(() => {
navigateToWorkchatUrl('/agents');
}, [navigateToWorkchatUrl]);
const onSaveSuccess = useCallback(() => {
notifications.toasts.addSuccess(
agentId
? agentLabels.notifications.agentUpdatedToastText
: agentLabels.notifications.agentCreatedToastText
);
navigateToWorkchatUrl('/agents');
}, [agentId, navigateToWorkchatUrl, notifications]);
const { editState, setFieldValue, submit, isSubmitting } = useAgentEdition({
agentId,
onSaveSuccess,
});
const onSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) {
return;
}
submit();
},
[submit, isSubmitting]
);
return (
<KibanaPageTemplate panelled>
<KibanaPageTemplate.Header
pageTitle={
agentId ? agentLabels.editView.editAgentTitle : agentLabels.editView.createAgentTitle
}
/>
<KibanaPageTemplate.Section grow={false} paddingSize="m">
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiForm component="form" fullWidth onSubmit={onSubmit}>
<EuiDescribedFormGroup
ratio="third"
title={<h3>Base configuration</h3>}
description="Configure your agent"
>
<EuiFormRow label="Name">
<EuiFieldText
data-test-subj="workchatAppAgentEditViewFieldText"
name="name"
value={editState.name}
onChange={(e) => setFieldValue('name', e.target.value)}
/>
</EuiFormRow>
<EuiFormRow label="Description">
<EuiFieldText
data-test-subj="workchatAppAgentEditViewFieldText"
name="description"
value={editState.description}
onChange={(e) => setFieldValue('description', e.target.value)}
/>
</EuiFormRow>
<EuiFormRow label="Visibility">
<EuiSelect
data-test-subj="workchatAppAgentEditViewSelect"
name="public"
value={editState.public ? 'public' : 'private'}
options={[
{ value: 'public', text: 'Public - everyone can use it' },
{ value: 'private', text: 'Private - only you can use it' },
]}
onChange={(e) => setFieldValue('public', e.target.value === 'public')}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<EuiSpacer />
<EuiDescribedFormGroup
ratio="third"
title={<h3>Customization</h3>}
description="Optional parameters to customize the agent"
>
<EuiFormRow label="System prompt">
<EuiTextArea
data-test-subj="workchatAppAgentEditViewTextArea"
name="systemPrompt"
value={editState.systemPrompt}
onChange={(e) => setFieldValue('systemPrompt', e.target.value)}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="workchatAppAgentEditViewCancelButton"
type="button"
iconType="framePrevious"
color="warning"
onClick={handleCancel}
>
{agentLabels.editView.cancelButtonLabel}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="workchatAppAgentEditViewSaveButton"
type="submit"
iconType="save"
fill
disabled={isSubmitting}
>
{agentLabels.editView.saveButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</EuiPanel>
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -1,50 +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 { i18n } from '@kbn/i18n';
export const agentLabels = {
breadcrumb: {
agentsPill: i18n.translate('workchatApp.agents.breadcrumb.agents', {
defaultMessage: 'Agents',
}),
editAgentPill: i18n.translate('workchatApp.agents.breadcrumb.editAgent', {
defaultMessage: 'Edit agent',
}),
createAgensPill: i18n.translate('workchatApp.agents.breadcrumb.createAgent', {
defaultMessage: 'Create agent',
}),
},
notifications: {
agentCreatedToastText: i18n.translate(
'workchatApp.agents.notifications.agentCreatedToastText',
{
defaultMessage: 'Agent created',
}
),
agentUpdatedToastText: i18n.translate(
'workchatApp.agents.notifications.agentCreatedToastText',
{
defaultMessage: 'Agent updated',
}
),
},
editView: {
createAgentTitle: i18n.translate('workchatApp.agents.editView.createTitle', {
defaultMessage: 'Create a new agent',
}),
editAgentTitle: i18n.translate('workchatApp.agents.editView.editTitle', {
defaultMessage: 'Edit agent',
}),
cancelButtonLabel: i18n.translate('workchatApp.agents.editView.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
saveButtonLabel: i18n.translate('workchatApp.agents.editView.saveButtonLabel', {
defaultMessage: 'Save',
}),
},
};

View file

@ -1,63 +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, EuiButton } from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { Agent } from '../../../../../common/agents';
import { useNavigation } from '../../../hooks/use_navigation';
import { appPaths } from '../../../app_paths';
interface AgentListViewProps {
agents: Agent[];
}
export const AgentListView: React.FC<AgentListViewProps> = ({ agents }) => {
const { navigateToWorkchatUrl } = useNavigation();
const columns: Array<EuiBasicTableColumn<Agent>> = [
{ field: 'name', name: 'Name' },
{ field: 'description', name: 'Description' },
{ field: 'user.name', name: 'Created by' },
{ field: 'public', name: 'Public' },
{
name: 'Actions',
actions: [
{
name: 'Edit',
description: 'Edit this agent',
isPrimary: true,
icon: 'documentEdit',
type: 'icon',
onClick: ({ id }) => {
navigateToWorkchatUrl(appPaths.agents.edit({ agentId: id }));
},
'data-test-subj': 'agentListTable-edit-btn',
},
],
},
];
return (
<KibanaPageTemplate panelled>
<KibanaPageTemplate.Header pageTitle="Agents" />
<KibanaPageTemplate.Section grow={false} paddingSize="m">
<EuiButton
onClick={() => {
return navigateToWorkchatUrl('/agents/create');
}}
>
Create new agent
</EuiButton>
</KibanaPageTemplate.Section>
<KibanaPageTemplate.Section>
<EuiBasicTable columns={columns} items={agents} />
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -0,0 +1,170 @@
/*
* 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, { Fragment, useCallback, useEffect } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiFieldText,
EuiSuperSelect,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormProvider, useForm, Controller } from 'react-hook-form';
import { useKibana } from '../../hooks/use_kibana';
import { AgentEditState, useAgentEdition } from '../../hooks/use_agent_edition';
import type { Agent } from '../../../../common/agents';
import { appPaths } from '../../app_paths';
import { useNavigation } from '../../hooks/use_navigation';
import { assistantLabels } from './i18n';
import { ASSISTANT_USE_CASES } from './constants';
export interface CreateNewAssistantModalProps {
onClose: () => void;
}
export const CreateNewAssistantModal: React.FC<CreateNewAssistantModalProps> = ({ onClose }) => {
const {
services: { notifications },
} = useKibana();
const { navigateToWorkchatUrl } = useNavigation();
const onSaveSuccess = useCallback(
(agent: Agent) => {
notifications.toasts.addSuccess(
i18n.translate('workchatApp.assistants.createSuccessMessage', {
defaultMessage: 'Assistant created successfully',
})
);
onClose();
navigateToWorkchatUrl(appPaths.assistants.edit({ agentId: agent.id }));
},
[notifications, onClose, navigateToWorkchatUrl]
);
const onSaveError = useCallback(
(err: Error) => {
notifications.toasts.addError(err, {
title: 'Error',
});
},
[notifications]
);
const { state, isSubmitting, submit } = useAgentEdition({
onSaveSuccess,
onSaveError,
});
const formMethods = useForm<AgentEditState>({
values: state,
});
const { handleSubmit, control, watch, setValue } = formMethods;
const useCase = watch('useCase');
useEffect(() => {
if (useCase) {
const selectedUseCase = ASSISTANT_USE_CASES.find((uc) => uc.value === useCase);
if (selectedUseCase) {
setValue('systemPrompt', selectedUseCase.prompt);
}
}
}, [useCase, setValue]);
return (
<EuiModal onClose={onClose} style={{ width: 640 }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('workchatApp.assistants.create.title', {
defaultMessage: 'Create new assistant',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormProvider {...formMethods}>
<EuiForm component="form" onSubmit={handleSubmit((data) => submit(data))} fullWidth>
<EuiFormRow
label={i18n.translate('workchatApp.assistants.create.nameLabel', {
defaultMessage: 'Name',
})}
fullWidth
>
<Controller
rules={{ required: true }}
name="name"
control={control}
render={({ field }) => (
<EuiFieldText data-test-subj="assistantNameInput" {...field} fullWidth />
)}
/>
</EuiFormRow>
<Controller
name="useCase"
control={control}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editPromptModal.useCaseLabel', {
defaultMessage: 'Use case',
})}
fullWidth
>
<EuiSuperSelect
data-test-subj="assistantUseCaseSelect"
options={ASSISTANT_USE_CASES.map(({ label, value, description }) => ({
inputDisplay: label,
value,
dropdownDisplay: (
<Fragment>
<strong>{label}</strong>
{!!description && (
<EuiText size="s" color="subdued">
{description}
</EuiText>
)}
</Fragment>
),
}))}
{...field}
valueOfSelected={field.value}
hasDividers
fullWidth
/>
</EuiFormRow>
)}
/>
</EuiForm>
</FormProvider>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>
{assistantLabels.editView.cancelButtonLabel}
</EuiButtonEmpty>
<EuiButton
data-test-subj="saveBasicInfoButton"
fill
onClick={handleSubmit((data) => submit(data))}
isLoading={isSubmitting}
>
{assistantLabels.editView.saveButtonLabel}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 ASSISTANT_USE_CASES = [
{
value: 'customerSupport',
label: i18n.translate('workchatApp.assistants.editView.useCase.customerSupportLabel', {
defaultMessage: 'Customer Support',
}),
description: i18n.translate(
'workchatApp.assistants.editView.useCase.customerSupportDescription',
{
defaultMessage: 'Help customers with inquiries and support issues',
}
),
prompt:
'You are a helpful customer support assistant. Provide clear, accurate, and friendly responses to customer inquiries. Focus on resolving issues efficiently while maintaining a professional tone.',
},
{
value: 'dataAnalysis',
label: i18n.translate('workchatApp.assistants.editView.useCase.dataAnalysisLabel', {
defaultMessage: 'Data Analysis',
}),
description: i18n.translate('workchatApp.assistants.editView.useCase.dataAnalysisDescription', {
defaultMessage: 'Analyze and interpret data to provide insights',
}),
prompt:
'You are a data analysis assistant. Help users understand their data by identifying patterns, trends, and anomalies. Provide clear explanations of your findings and suggest actionable insights.',
},
{
value: 'custom',
label: i18n.translate('workchatApp.assistants.editView.useCase.customLabel', {
defaultMessage: 'Custom',
}),
description: i18n.translate('workchatApp.assistants.editView.useCase.customDescription', {
defaultMessage: 'Create a custom assistant for your specific needs',
}),
prompt: '',
},
];

View file

@ -0,0 +1,266 @@
/*
* 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, useState, useCallback } from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiPanel,
EuiFlexItem,
EuiSpacer,
EuiIcon,
EuiText,
EuiTitle,
EuiLink,
EuiLoadingElastic,
EuiAvatar,
} from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { i18n } from '@kbn/i18n';
import { useNavigation } from '../../../hooks/use_navigation';
import { useBreadcrumb } from '../../../hooks/use_breadcrumbs';
import { appPaths } from '../../../app_paths';
import { assistantLabels } from '../i18n';
import { useAgent } from '../../../hooks/use_agent';
import { useConversationList } from '../../../hooks/use_conversation_list';
import { sortAndGroupConversations } from '../../../utils/sort_and_group_conversations';
import { sliceRecentConversations } from '../../../utils/slice_recent_conversations';
import { EditAssistantBasicInfo } from './assistant_edit_basic_info_modal';
import { EditPrompt } from './assistant_edit_prompt_modal';
interface AssistantDetailsProps {
agentId: string;
}
export const AssistantDetails: React.FC<AssistantDetailsProps> = ({ agentId }) => {
const { navigateToWorkchatUrl, createWorkchatUrl } = useNavigation();
const { agent: assistant, isLoading, refetch } = useAgent({ agentId });
const { conversations } = useConversationList({ agentId });
// State for modals
const [isBasicInfoModalVisible, setIsBasicInfoModalVisible] = useState(false);
const [isPromptModalVisible, setIsPromptModalVisible] = useState(false);
const conversationGroups = useMemo(() => {
return sortAndGroupConversations(sliceRecentConversations(conversations, 10));
}, [conversations]);
const breadcrumb = useMemo(() => {
return [
{
text: assistantLabels.breadcrumb.assistantsPill,
href: createWorkchatUrl(appPaths.assistants.list),
},
{ text: assistantLabels.breadcrumb.assistantDetailsPill },
];
}, [createWorkchatUrl]);
useBreadcrumb(breadcrumb);
const handleOpenBasicInfoModal = useCallback(() => {
setIsBasicInfoModalVisible(true);
}, []);
const handleCloseBasicInfoModal = useCallback(() => {
setIsBasicInfoModalVisible(false);
}, []);
const handleOpenPromptModal = useCallback(() => {
setIsPromptModalVisible(true);
}, []);
const handleClosePromptModal = useCallback(() => {
setIsPromptModalVisible(false);
}, []);
const handleSaveSuccess = useCallback(() => {
refetch();
}, [refetch]);
if (isLoading || !assistant) {
return (
<KibanaPageTemplate.EmptyPrompt>
<EuiLoadingElastic />
</KibanaPageTemplate.EmptyPrompt>
);
}
const AssistantBasicInfo = () => (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFlexGroup alignItems="flexStart" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFlexGroup alignItems="center" direction="row" gutterSize="xl">
<EuiFlexItem grow={false}>
<EuiAvatar
size="xl"
name={assistant.name}
initials={assistant.avatar?.text}
color={assistant.avatar?.color}
/>
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiTitle size="s">
<span>{assistant.name}</span>
</EuiTitle>
</EuiFlexItem>
<EuiText size="s" color="subdued">
{assistant.description}
</EuiText>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
iconType="pencil"
color="text"
onClick={handleOpenBasicInfoModal}
data-test-subj="editAssistantBasicInfoButton"
>
{assistantLabels.editView.editButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
const AssistantPrompt = () => (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFlexGroup alignItems="flexStart" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiText size="m" color="subdued">
{assistant?.configuration?.systemPrompt}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
iconType="pencil"
color="text"
onClick={handleOpenPromptModal}
data-test-subj="editAssistantPromptButton"
>
{assistantLabels.editView.editButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
const AssistantChatHistory = () => (
<EuiPanel hasShadow={false} hasBorder={true}>
<EuiFlexGroup direction="column" gutterSize="l">
{conversationGroups.map(({ conversations: groupConversations, dateLabel }) => (
<EuiFlexItem key={dateLabel}>
<EuiPanel hasBorder={false} hasShadow={false} color="transparent" paddingSize="s">
<EuiText size="s">
<h4>{dateLabel}</h4>
</EuiText>
</EuiPanel>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="m">
{groupConversations.map((conversation) => (
<EuiFlexItem key={conversation.id}>
<EuiLink
color="subdued"
onClick={() => {
navigateToWorkchatUrl(
appPaths.chat.conversation({
agentId: assistant.id,
conversationId: conversation.id,
})
);
}}
>
{conversation.title}
</EuiLink>
</EuiFlexItem>
))}
</EuiFlexGroup>
<EuiSpacer size="s" />
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPanel>
);
return (
<>
<KibanaPageTemplate.Section paddingSize="m">
<EuiSpacer size="l" />
<EuiFlexGroup gutterSize="xl" alignItems="flexStart" direction="row">
<EuiFlexItem grow={2}>
<EuiFlexGroup gutterSize="l" direction="column">
<EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiIcon type="user" size="m" />
<EuiTitle size="xxs">
<h4>
{i18n.translate('workchatApp.assistants.basicInfoTitle', {
defaultMessage: 'Basic',
})}
</h4>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
<AssistantBasicInfo />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiIcon type="gear" size="m" />
<EuiTitle size="xxs">
<h4>
{i18n.translate('workchatApp.assistants.promptTitle', {
defaultMessage: 'Prompt',
})}
</h4>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
<AssistantPrompt />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<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="s" />
<AssistantChatHistory />
</EuiFlexItem>
</EuiFlexGroup>
</KibanaPageTemplate.Section>
{/* Modals */}
{isBasicInfoModalVisible && (
<EditAssistantBasicInfo
agentId={agentId}
onClose={handleCloseBasicInfoModal}
onSaveSuccess={handleSaveSuccess}
/>
)}
{isPromptModalVisible && (
<EditPrompt
agentId={agentId}
onClose={handleClosePromptModal}
onSaveSuccess={handleSaveSuccess}
/>
)}
</>
);
};

View file

@ -0,0 +1,226 @@
/*
* 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 { useForm, Controller } from 'react-hook-form';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiTextArea,
EuiColorPicker,
EuiText,
EuiFieldText,
EuiFormHelpText,
EuiAvatar,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiPaletteColorBlind } from '@elastic/eui';
import { useKibana } from '../../../hooks/use_kibana';
import { useAgentEdition } from '../../../hooks/use_agent_edition';
import { assistantLabels } from '../i18n';
export interface EditAssistantBasicInfoProps {
onClose: () => void;
onSaveSuccess: () => void;
agentId: string;
}
const AVATAR_COLORS = euiPaletteColorBlind();
export const EditAssistantBasicInfo: React.FC<EditAssistantBasicInfoProps> = ({
onClose,
agentId,
onSaveSuccess,
}) => {
const {
services: { notifications },
} = useKibana();
const { state, submit, isSubmitting } = useAgentEdition({
agentId,
onSaveSuccess: () => {
notifications.toasts.addSuccess(
i18n.translate('workchatApp.assistants.editBasicsModal.saveSuccessMessage', {
defaultMessage: 'Assistant updated successfully',
})
);
onSaveSuccess();
onClose();
},
});
const { control, handleSubmit, watch } = useForm({
values: state,
});
// Get form values for the avatar preview
const name = watch('name');
const avatarCustomText = watch('avatarCustomText');
const avatarColor = watch('avatarColor');
return (
<EuiModal onClose={onClose} style={{ width: 800 }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('workchatApp.assistants.editBasicsModal.title', {
defaultMessage: 'Edit Assistant Basics',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm component="form" onSubmit={handleSubmit((data) => submit(data))} fullWidth>
<EuiText>
<h4>
{i18n.translate('workchatApp.assistants.editBasicsModal.identificationSection', {
defaultMessage: 'Identification',
})}
</h4>
</EuiText>
<EuiSpacer size="m" />
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editBasicsModal.nameLabel', {
defaultMessage: 'Name',
})}
fullWidth
>
<EuiFieldText data-test-subj="assistantNameInput" {...field} fullWidth />
</EuiFormRow>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editBasicsModal.descriptionLabel', {
defaultMessage: 'Description',
})}
fullWidth
>
<EuiTextArea
data-test-subj="assistantDescriptionInput"
{...field}
fullWidth
rows={6}
/>
</EuiFormRow>
)}
/>
<EuiFormHelpText>
{i18n.translate('workchatApp.assistants.editBasicsModal.descriptionHelpText', {
defaultMessage: 'Describe what this assistant is going to be used for.',
})}
</EuiFormHelpText>
<EuiSpacer size="l" />
<EuiText>
<h4>
{i18n.translate('workchatApp.assistants.editBasicsModal.avatarSection', {
defaultMessage: 'Avatar',
})}
</h4>
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiAvatar initials={avatarCustomText} name={name} color={avatarColor} size="xl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<Controller
name="avatarColor"
control={control}
render={({ field: { onChange, value } }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editBasicsModal.colorLabel', {
defaultMessage: 'Color',
})}
fullWidth
>
<EuiColorPicker
data-test-subj="assistantAvatarColorPicker"
onChange={onChange}
color={value}
swatches={AVATAR_COLORS}
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<Controller
name="avatarCustomText"
control={control}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editBasicsModal.textLabel', {
defaultMessage: 'Custom Text',
})}
helpText={i18n.translate(
'workchatApp.assistants.editBasicsModal.emojiHelpText',
{
defaultMessage: 'Press CTRL + CMD + Space for emojis',
}
)}
fullWidth
>
<EuiFieldText
data-test-subj="assistantAvatarTextField"
{...field}
fullWidth
/>
</EuiFormRow>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>
{assistantLabels.editView.cancelButtonLabel}
</EuiButtonEmpty>
<EuiButton
data-test-subj="saveBasicInfoButton"
fill
onClick={handleSubmit((data) => submit(data))}
isLoading={isSubmitting}
>
{assistantLabels.editView.saveButtonLabel}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -0,0 +1,168 @@
/*
* 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, { Fragment, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
EuiButton,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiSuperSelect,
EuiText,
EuiTextArea,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../../hooks/use_kibana';
import { useAgentEdition } from '../../../hooks/use_agent_edition';
import { assistantLabels } from '../i18n';
import { ASSISTANT_USE_CASES } from '../constants';
export interface EditPromptProps {
onClose: () => void;
onSaveSuccess: () => void;
agentId: string;
}
export const EditPrompt: React.FC<EditPromptProps> = ({ onClose, onSaveSuccess, agentId }) => {
const {
services: { notifications },
} = useKibana();
const { state, submit, isSubmitting } = useAgentEdition({
agentId,
onSaveSuccess: () => {
notifications.toasts.addSuccess(
i18n.translate('workchatApp.assistants.editPromptModal.saveSuccessMessage', {
defaultMessage: 'Assistant updated successfully',
})
);
onSaveSuccess();
onClose();
},
});
const { control, handleSubmit, watch, setValue } = useForm({
values: state,
});
const useCase = watch('useCase');
useEffect(() => {
if (useCase && useCase !== 'custom') {
const selectedUseCase = ASSISTANT_USE_CASES.find((uc) => uc.value === useCase);
if (selectedUseCase && selectedUseCase.prompt) {
setValue('systemPrompt', selectedUseCase.prompt);
}
}
}, [useCase, setValue]);
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newPrompt = e.target.value;
setValue('systemPrompt', newPrompt);
if (useCase !== 'custom') {
setValue('useCase', 'custom');
}
};
return (
<EuiModal onClose={onClose} style={{ width: 800 }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('workchatApp.assistants.editPromptModal.title', {
defaultMessage: 'Edit prompt',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm component="form" onSubmit={handleSubmit((data) => submit(data))} fullWidth>
<Controller
name="useCase"
control={control}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editPromptModal.useCaseLabel', {
defaultMessage: 'Use case',
})}
fullWidth
>
<EuiSuperSelect
data-test-subj="assistantUseCaseSelect"
options={ASSISTANT_USE_CASES.map(({ label, value, description }) => ({
inputDisplay: label,
value,
dropdownDisplay: (
<Fragment>
<strong>{label}</strong>
{!!description && (
<EuiText size="s" color="subdued">
{description}
</EuiText>
)}
</Fragment>
),
}))}
{...field}
valueOfSelected={field.value}
hasDividers
fullWidth
/>
</EuiFormRow>
)}
/>
<EuiSpacer size="m" />
<Controller
name="systemPrompt"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiFormRow
label={i18n.translate('workchatApp.assistants.editPromptModal.promptLabel', {
defaultMessage: 'Prompt',
})}
fullWidth
>
<EuiTextArea
data-test-subj="assistantPromptTextArea"
{...field}
onChange={handlePromptChange}
fullWidth
rows={8}
/>
</EuiFormRow>
)}
/>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>
{assistantLabels.editView.cancelButtonLabel}
</EuiButtonEmpty>
<EuiButton
data-test-subj="savePromptButton"
fill
onClick={handleSubmit((data) => submit(data))}
isLoading={isSubmitting}
>
{assistantLabels.editView.saveButtonLabel}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -0,0 +1,76 @@
/*
* 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 { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { useNavigation } from '../../../hooks/use_navigation';
import { appPaths } from '../../../app_paths';
import { AssistantDetails } from './assistant_details';
import { AssistantWorkflow } from './assistant_workflow';
import { useAgent } from '../../../hooks/use_agent';
interface AssistantViewProps {
agentId: string;
selectedTab: 'details' | 'workflow';
}
export const AssistantView: React.FC<AssistantViewProps> = ({ agentId, selectedTab }) => {
const { navigateToWorkchatUrl } = useNavigation();
const { agent } = useAgent({ agentId });
const tabs = [
{
label: i18n.translate('workchatApp.assistant.tabs.overviewTitle', {
defaultMessage: 'Overview',
}),
id: 'details',
onClick: () => navigateToWorkchatUrl(appPaths.assistants.edit({ agentId })),
},
{
label: i18n.translate('workchatApp.assistant.tabs.workflowTitle', {
defaultMessage: 'Workflows',
}),
id: 'workflow',
onClick: () => navigateToWorkchatUrl(appPaths.assistants.workflow({ agentId })),
},
];
const headerButtons = [
<EuiButton
iconType={'newChat'}
color="primary"
fill
iconSide="left"
onClick={() => navigateToWorkchatUrl(appPaths.chat.new({ agentId }))}
>
New conversation
</EuiButton>,
<EuiButtonEmpty iconType={'questionInCircle'} color="primary" iconSide="left" href="/">
Learn more
</EuiButtonEmpty>,
];
return (
<KibanaPageTemplate data-test-subj="workChatEditAssistantPage">
<KibanaPageTemplate.Header
pageTitle={agent?.name}
rightSideItems={headerButtons}
tabs={tabs.map((tab) => {
return {
...tab,
isSelected: tab.id === selectedTab,
};
})}
/>
{selectedTab === 'details' && <AssistantDetails agentId={agentId} />}
{selectedTab === 'workflow' && <AssistantWorkflow agentId={agentId} />}
</KibanaPageTemplate>
);
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useNavigation } from '../../../hooks/use_navigation';
import { assistantLabels } from '../i18n';
import { appPaths } from '../../../app_paths';
import { useBreadcrumb } from '../../../hooks/use_breadcrumbs';
interface AssistantWorkflowProps {
agentId: string;
}
export const AssistantWorkflow: React.FC<AssistantWorkflowProps> = ({ agentId }) => {
const { createWorkchatUrl } = useNavigation();
const breadcrumb = useMemo(() => {
return [
{
text: assistantLabels.breadcrumb.assistantsPill,
href: createWorkchatUrl(appPaths.assistants.list),
},
{ text: assistantLabels.breadcrumb.assistantWorkflowPill },
];
}, [createWorkchatUrl]);
useBreadcrumb(breadcrumb);
return <KibanaPageTemplate.Section paddingSize="m">todo...</KibanaPageTemplate.Section>;
};

View file

@ -0,0 +1,53 @@
/*
* 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 assistantLabels = {
breadcrumb: {
assistantsPill: i18n.translate('workchatApp.assistants.breadcrumb.assistants', {
defaultMessage: 'Assistants',
}),
assistantDetailsPill: i18n.translate('workchatApp.assistants.breadcrumb.assistantOverview', {
defaultMessage: 'Overview',
}),
assistantWorkflowPill: i18n.translate('workchatApp.assistants.breadcrumb.assistantWorkflow', {
defaultMessage: 'Workflows',
}),
},
notifications: {
assistantCreatedToastText: i18n.translate(
'workchatApp.assistants.notifications.assistantCreatedToastText',
{
defaultMessage: 'Assistant created',
}
),
assistantUpdatedToastText: i18n.translate(
'workchatApp.assistants.notifications.assistantCreatedToastText',
{
defaultMessage: 'Assistant updated',
}
),
},
editView: {
createassistantTitle: i18n.translate('workchatApp.assistants.editView.createTitle', {
defaultMessage: 'Create a new assistant',
}),
editassistantTitle: i18n.translate('workchatApp.assistants.editView.editTitle', {
defaultMessage: 'Edit assistant',
}),
cancelButtonLabel: i18n.translate('workchatApp.assistants.editView.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
saveButtonLabel: i18n.translate('workchatApp.assistants.editView.saveButtonLabel', {
defaultMessage: 'Save',
}),
editButtonLabel: i18n.translate('workchatApp.assistants.editView.editButtonLabel', {
defaultMessage: 'Edit',
}),
},
};

View file

@ -0,0 +1,210 @@
/*
* 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 } from 'react';
import {
Comparators,
Criteria,
EuiAvatar,
EuiBasicTable,
EuiBasicTableColumn,
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiSpacer,
EuiTableSortingType,
EuiText,
} from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { Agent } from '../../../../../common/agents';
import { useNavigation } from '../../../hooks/use_navigation';
import { appPaths } from '../../../app_paths';
import { CreateNewAssistantModal } from '../assistant_create_modal';
interface AssistantListViewProps {
agents: Agent[];
}
export const AssistantListView: React.FC<AssistantListViewProps> = ({ agents }) => {
const { navigateToWorkchatUrl } = useNavigation();
const [sortField, setSortField] = useState<keyof Agent>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [createModalOpen, setCreateModalOpen] = useState(false);
const columns: Array<EuiBasicTableColumn<Agent>> = [
{
field: 'name',
name: 'Name',
sortable: true,
render: (name: Agent['name'], agent: Agent) => (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiAvatar
size="m"
name={agent.name}
initials={agent.avatar?.text}
color={agent.avatar?.color}
/>
<EuiButtonEmpty
onClick={() => navigateToWorkchatUrl(appPaths.assistants.edit({ agentId: agent.id }))}
>
{name}
</EuiButtonEmpty>
</EuiFlexGroup>
),
},
{ field: 'user.name', name: 'Created by' },
{ field: 'public', name: 'Visibility' },
{
name: 'Actions',
actions: [
{
name: 'Edit',
description: 'Edit this agent',
isPrimary: true,
icon: 'documentEdit',
type: 'icon',
onClick: ({ id }) => {
navigateToWorkchatUrl(appPaths.assistants.edit({ agentId: id }));
},
'data-test-subj': 'agentListTable-edit-btn',
},
],
},
];
const findAgents = (
agentsList: Agent[],
currentPageIndex: number,
currentPageSize: number,
currentSortField: keyof Agent,
currentSortDirection: 'asc' | 'desc'
) => {
let items;
if (currentSortField) {
items = agentsList
.slice(0)
.sort(Comparators.property(currentSortField, Comparators.default(currentSortDirection)));
} else {
items = agentsList;
}
let pageOfItems;
if (!currentPageIndex && !currentPageSize) {
pageOfItems = items;
} else {
const startIndex = currentPageIndex * currentPageSize;
pageOfItems = items.slice(
startIndex,
Math.min(startIndex + currentPageSize, agentsList.length)
);
}
return {
pageOfItems,
totalItemCount: agentsList.length,
};
};
const headerButtons = [
<EuiButton
iconType={'plusInCircle'}
color="primary"
fill
iconSide="left"
onClick={() => setCreateModalOpen(true)}
>
New
</EuiButton>,
<EuiButtonEmpty iconType={'questionInCircle'} color="primary" iconSide="left" href="/">
Learn more
</EuiButtonEmpty>,
];
const onTableChange = ({ page, sort }: Criteria<Agent>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
if (sort) {
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
}
};
const { pageOfItems, totalItemCount } = findAgents(
agents,
pageIndex,
pageSize,
sortField,
sortDirection
);
const sorting: EuiTableSortingType<Agent> = {
sort: {
field: sortField,
direction: sortDirection,
},
};
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 20, 50],
};
const resultsCount = (
<FormattedMessage
id="workchatApp.assistants.list.resultsCountLabel"
defaultMessage="Showing {start}-{end} of {total}"
values={{
start: <strong>{pageSize * pageIndex + 1}</strong>,
end: <strong>{pageSize * pageIndex + pageSize}</strong>,
total: totalItemCount,
}}
/>
);
return (
<KibanaPageTemplate data-test-subj="workChatAssistantsListPage">
<KibanaPageTemplate.Header
pageTitle={i18n.translate('workchatApp.assistants.pageTitle', {
defaultMessage: 'Assistants',
})}
description={i18n.translate('workchatApp.assistants.pageDescription', {
defaultMessage:
'An assistant is an AI helper built around your data. It can search, summarize, and take action using your connected integrations. You can manage or create assistants from here.',
})}
rightSideItems={headerButtons}
/>
<KibanaPageTemplate.Section>
<EuiText size="xs">{resultsCount}</EuiText>
<EuiSpacer size="m" />
<EuiBasicTable
columns={columns}
items={pageOfItems}
sorting={sorting}
onChange={onTableChange}
pagination={pagination}
/>
{createModalOpen && <CreateNewAssistantModal onClose={() => setCreateModalOpen(false)} />}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -97,6 +97,8 @@ export const ChatNewConversationPrompt: React.FC<ChatNewConversationPromptProps>
name={agent?.name ?? chatCommonLabels.assistant.defaultNameLabel}
size="xl"
type="user"
initials={agent?.avatar?.text}
color={agent?.avatar?.color}
/>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -34,7 +34,13 @@ export const AssistantBlock: React.FC<AssistantBlockProps> = ({ agentId }) => {
return (
<EuiFlexGroup direction="row" alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiAvatar name={agent?.name ?? 'Assistant'} size="l" type="user" />
<EuiAvatar
name={agent?.name ?? 'Assistant'}
size="l"
type="user"
initials={agent?.avatar?.text}
color={agent?.avatar?.color}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFlexGroup

View file

@ -33,14 +33,19 @@ export const HomeAssistantsSection: React.FC<{}> = () => {
<EuiPanel key={assistant.id} paddingSize="m" hasBorder={true}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiAvatar size="l" name={assistant.name} />
<EuiAvatar
size="l"
name={assistant.name}
initials={assistant.avatar?.text}
color={assistant.avatar?.color}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="gear"
color="text"
onClick={() => {
navigateToWorkchatUrl(appPaths.agents.edit({ agentId: assistant.id }));
navigateToWorkchatUrl(appPaths.assistants.edit({ agentId: assistant.id }));
}}
/>
</EuiFlexItem>

View file

@ -28,8 +28,8 @@ import { appPaths } from '../../app_paths';
export const HomeConversationHistorySection: React.FC<{}> = () => {
const { navigateToWorkchatUrl } = useNavigation();
const { agents } = useAgentList();
const { conversations } = useConversationList({});
const { agents, isLoading: isAgentsLoading } = useAgentList();
const { conversations, isLoading: isConversationHistoryLoading } = useConversationList({});
const agentMap = useMemo<Record<string, Agent>>(() => {
return agents.reduce<Record<string, Agent>>((map, agent) => {
@ -42,6 +42,10 @@ export const HomeConversationHistorySection: React.FC<{}> = () => {
return sortAndGroupConversations(sliceRecentConversations(conversations, 10));
}, [conversations]);
if (isAgentsLoading || isConversationHistoryLoading) {
return;
}
const recentConversations = conversationGroups.map(
({ conversations: groupConversations, dateLabel }) => {
return (
@ -54,14 +58,21 @@ export const HomeConversationHistorySection: React.FC<{}> = () => {
<EuiFlexGroup direction={'column'}>
{groupConversations.map((conversation) => {
const agent = agentMap[conversation.agentId];
if (!agent) {
return null;
}
return (
<EuiFlexItem key={conversation.id}>
<EuiFlexGroup gutterSize="s" alignItems="center">
{agent && (
<EuiFlexItem grow={false}>
<EuiAvatar name={agent.name} size="s" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiAvatar
name={agent.name}
initials={agent.avatar.text}
color={agent.avatar.color}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem direction="column" grow={false}>
<EuiLink
onClick={() => {

View file

@ -12,7 +12,11 @@ import { queryKeys } from '../query_keys';
export const useAgent = ({ agentId }: { agentId: string }) => {
const { agentService } = useWorkChatServices();
const { data: agent, isLoading } = useQuery({
const {
data: agent,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.agents.details(agentId),
queryFn: async () => {
return agentService.get(agentId);
@ -22,5 +26,6 @@ export const useAgent = ({ agentId }: { agentId: string }) => {
return {
agent,
isLoading,
refetch,
};
};

View file

@ -13,6 +13,9 @@ export interface AgentEditState {
name: string;
description: string;
systemPrompt: string;
avatarColor?: string;
avatarCustomText?: string;
useCase?: string;
public: boolean;
}
@ -21,6 +24,9 @@ const emptyState = (): AgentEditState => {
name: '',
description: '',
systemPrompt: '',
avatarColor: undefined,
avatarCustomText: '',
useCase: '',
public: false,
};
};
@ -28,63 +34,74 @@ const emptyState = (): AgentEditState => {
export const useAgentEdition = ({
agentId,
onSaveSuccess,
onSaveError,
}: {
agentId: string | undefined;
agentId?: string;
onSaveSuccess: (agent: Agent) => void;
onSaveError?: (err: Error) => void;
}) => {
const { agentService } = useWorkChatServices();
const [editState, setEditState] = useState<AgentEditState>(emptyState());
const [state, setState] = useState<AgentEditState>(emptyState());
const [isSubmitting, setSubmitting] = useState<boolean>(false);
useEffect(() => {
const fetchAgent = async () => {
if (agentId) {
const agent = await agentService.get(agentId);
setEditState({
setState({
name: agent.name,
description: agent.description,
systemPrompt: agent.configuration.systemPrompt ?? '',
public: agent.public,
useCase: agent.configuration.useCase ?? '',
avatarColor: agent.avatar.color,
avatarCustomText: agent.avatar.text ?? '',
});
}
};
fetchAgent();
}, [agentId, agentService]);
const setFieldValue = <T extends keyof AgentEditState>(key: T, value: AgentEditState[T]) => {
setEditState((previous) => ({ ...previous, [key]: value }));
};
const submit = useCallback(
(updatedAgent: AgentEditState) => {
setSubmitting(true);
const submit = useCallback(() => {
setSubmitting(true);
const payload = {
name: updatedAgent.name,
description: updatedAgent.description,
configuration: {
systemPrompt: updatedAgent.systemPrompt,
useCase: updatedAgent.useCase,
},
avatar: {
color: updatedAgent.avatarColor,
text: updatedAgent.avatarCustomText,
},
public: updatedAgent.public,
};
const payload = {
name: editState.name,
description: editState.description,
configuration: {
systemPrompt: editState.systemPrompt,
},
public: editState.public,
};
(agentId ? agentService.update(agentId, payload) : agentService.create(payload)).then(
(response) => {
setSubmitting(false);
if (response.success) {
onSaveSuccess(response.agent);
(agentId ? agentService.update(agentId, payload) : agentService.create(payload)).then(
(response) => {
setSubmitting(false);
if (response.success) {
onSaveSuccess(response.agent);
}
},
(err) => {
setSubmitting(false);
if (onSaveError) {
onSaveError(err);
}
}
},
(err) => {
setSubmitting(false);
}
);
}, [agentId, editState, agentService, onSaveSuccess]);
);
},
[agentId, agentService, onSaveSuccess, onSaveError]
);
return {
editState,
state,
isSubmitting,
setFieldValue,
submit,
};
};

View file

@ -1,24 +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 { useParams } from 'react-router-dom';
import { AgentEditView } from '../components/agents/edition/agent_edit_view';
const newAgentId = 'create';
export const WorkChatAgentEditOrCreatePage: React.FC<{}> = () => {
const { agentId: agentIdFromParams } = useParams<{
agentId: string;
}>();
const agentId = useMemo(() => {
return agentIdFromParams === newAgentId ? undefined : agentIdFromParams;
}, [agentIdFromParams]);
return <AgentEditView agentId={agentId} />;
};

View file

@ -0,0 +1,18 @@
/*
* 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 { useParams } from 'react-router-dom';
import { AssistantView } from '../components/assistant/details/assistant_view';
export const WorkChatAssistantOverviewPage: React.FC<{}> = () => {
const { agentId } = useParams<{
agentId: string;
}>();
return <AssistantView agentId={agentId} selectedTab="details" />;
};

View file

@ -0,0 +1,18 @@
/*
* 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 { useParams } from 'react-router-dom';
import { AssistantView } from '../components/assistant/details/assistant_view';
export const WorkChatAssistantWorkflowPage: React.FC<{}> = () => {
const { agentId } = useParams<{
agentId: string;
}>();
return <AssistantView agentId={agentId} selectedTab="workflow" />;
};

View file

@ -8,10 +8,10 @@
import React from 'react';
import { useBreadcrumb } from '../hooks/use_breadcrumbs';
import { useAgentList } from '../hooks/use_agent_list';
import { AgentListView } from '../components/agents/listing/agent_list_view';
import { AssistantListView } from '../components/assistant/list/assistant_list_view';
export const WorkChatAgentsPage: React.FC<{}> = () => {
useBreadcrumb([{ text: 'Agents' }]);
export const WorkChatAssistantsPage: React.FC<{}> = () => {
useBreadcrumb([{ text: 'Assistants' }]);
const { agents } = useAgentList();
return <AgentListView agents={agents} />;
return <AssistantListView agents={agents} />;
};

View file

@ -26,7 +26,7 @@ export const registerApp = ({
title: 'WorkChat',
updater$: undefined,
deepLinks: [
{ id: 'agents', path: '/agents', title: 'Agents' },
{ id: 'agents', path: '/assistants', title: 'Assistants' },
{ id: 'integrations', path: '/integrations', title: 'Integrations' },
],
visibleIn: ['sideNav', 'globalSearch'],

View file

@ -9,28 +9,32 @@ import React from 'react';
import { Route, Routes } from '@kbn/shared-ux-router';
import { WorkChatHomePage } from './pages/home';
import { WorkchatChatPage } from './pages/chat';
import { WorkChatAgentsPage } from './pages/agents';
import { WorkChatAgentEditOrCreatePage } from './pages/agent_edit_or_create';
import { WorkChatAssistantsPage } from './pages/assistants';
import { WorkChatAssistantOverviewPage } from './pages/assistant_details';
import { WorkChatIntegrationsPage } from './pages/integrations';
import { WorkChatIntegrationEditOrCreatePage } from './pages/integration_edit_or_create';
import { WorkChatAssistantWorkflowPage } from './pages/assistant_workflow';
export const WorkchatAppRoutes: React.FC<{}> = () => {
return (
<Routes>
<Route path="/agents/:agentId/chat/:conversationId">
<Route path="/assistants/:agentId/chat/:conversationId">
<WorkchatChatPage />
</Route>
<Route path="/agents/:agentId/chat">
<Route path="/assistants/:agentId/chat">
<WorkchatChatPage />
</Route>
<Route path="/agents/create" strict>
<WorkChatAgentEditOrCreatePage />
<Route path="/assistants/create" strict>
<WorkChatAssistantOverviewPage />
</Route>
<Route path="/agents/:agentId/edit" strict>
<WorkChatAgentEditOrCreatePage />
<Route path="/assistants/:agentId/edit" strict>
<WorkChatAssistantOverviewPage />
</Route>
<Route path="/agents" strict>
<WorkChatAgentsPage />
<Route path="/assistants/:agentId/workflow" strict>
<WorkChatAssistantWorkflowPage />
</Route>
<Route path="/assistants" strict>
<WorkChatAssistantsPage />
</Route>
<Route path="/integrations/create">

View file

@ -63,8 +63,13 @@ export const registerAgentRoutes = ({ getServices, router, logger }: RouteDepend
description: schema.string({ defaultValue: '' }),
configuration: schema.object({
systemPrompt: schema.maybe(schema.string()),
useCase: schema.maybe(schema.string()),
}),
public: schema.boolean({ defaultValue: false }),
avatar: schema.object({
color: schema.maybe(schema.string()),
text: schema.maybe(schema.string()),
}),
}),
},
},
@ -105,6 +110,11 @@ export const registerAgentRoutes = ({ getServices, router, logger }: RouteDepend
description: schema.string({ defaultValue: '' }),
configuration: schema.object({
systemPrompt: schema.maybe(schema.string()),
useCase: schema.maybe(schema.string()),
}),
avatar: schema.object({
color: schema.maybe(schema.string()),
text: schema.maybe(schema.string()),
}),
public: schema.boolean({ defaultValue: false }),
}),

View file

@ -15,7 +15,7 @@ export const agentSoType: SavedObjectsType<AgentAttributes> = {
hidden: true,
namespaceType: 'agnostic',
mappings: {
dynamic: 'strict',
dynamic: false,
properties: {
agent_id: { type: 'keyword' },
agent_name: { type: 'text' },
@ -44,4 +44,8 @@ export interface AgentAttributes {
access_control: {
public: boolean;
};
avatar: {
color?: string;
text?: string;
};
}

View file

@ -13,6 +13,7 @@ import { agentTypeName, type AgentAttributes } from '../../saved_objects/agents'
import { WorkchatError } from '../../errors';
import { createBuilder } from '../../utils/so_filters';
import { savedObjectToModel, createRequestToRaw, updateToAttributes } from './convert_model';
import { getRandomColorFromPalette } from '../../utils/color';
interface AgentClientOptions {
logger: Logger;
@ -21,7 +22,7 @@ interface AgentClientOptions {
}
export type AgentUpdatableFields = Partial<
Pick<Agent, 'name' | 'description' | 'configuration' | 'public'>
Pick<Agent, 'name' | 'description' | 'configuration' | 'public' | 'avatar'>
>;
/**
@ -83,12 +84,15 @@ export class AgentClientImpl implements AgentClient {
async create(createRequest: AgentCreateRequest): Promise<Agent> {
const now = new Date();
const id = createRequest.id ?? uuidv4();
const color = createRequest.avatar?.color ?? getRandomColorFromPalette();
const attributes = createRequestToRaw({
createRequest,
id,
user: this.user,
creationDate: now,
color,
});
const created = await this.client.create<AgentAttributes>(agentTypeName, attributes, { id });
return savedObjectToModel(created);
}

View file

@ -23,6 +23,10 @@ export const savedObjectToModel = ({ attributes }: SavedObject<AgentAttributes>)
},
configuration: attributes.configuration,
public: attributes.access_control.public,
avatar: {
color: attributes.avatar?.color,
text: attributes.avatar?.text,
},
};
};
@ -35,6 +39,7 @@ export const updateToAttributes = ({
agent_name: updatedFields.name,
description: updatedFields.description,
configuration: updatedFields.configuration,
avatar: updatedFields.avatar,
};
};
@ -43,11 +48,13 @@ export const createRequestToRaw = ({
id,
user,
creationDate,
color,
}: {
createRequest: AgentCreateRequest;
id: string;
user: UserNameAndId;
creationDate: Date;
color: string;
}): AgentAttributes => {
return {
agent_id: id,
@ -60,5 +67,9 @@ export const createRequestToRaw = ({
access_control: {
public: createRequest.public,
},
avatar: {
color: color || createRequest.avatar.color,
text: createRequest.avatar.text,
},
};
};

View file

@ -0,0 +1,13 @@
/*
* 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 { euiPaletteColorBlind } from '@elastic/eui';
export const getRandomColorFromPalette = (): string => {
const palette = euiPaletteColorBlind();
return palette[Math.floor(Math.random() * palette.length)];
};