chore: update

This commit is contained in:
xilesun 2025-04-09 23:51:59 +08:00
parent e79359c683
commit c3b03776ae
31 changed files with 1806 additions and 817 deletions

View File

@ -13,7 +13,7 @@
"@nocobase/test": "1.x"
},
"devDependencies": {
"@ant-design/x": "^1.0.5",
"@ant-design/x": "^1.1.0",
"@langchain/core": "^0.3.39",
"@langchain/deepseek": "^0.0.1",
"@langchain/openai": "^0.4.3",

View File

@ -12,6 +12,7 @@ import { createContext } from 'react';
import { ChatBoxProvider } from './chatbox/ChatBoxProvider';
import { useAPIClient, useRequest } from '@nocobase/client';
import { AIEmployee } from './types';
import { AISelectionProvider } from './selector/AISelectorProvider';
export const AIEmployeesContext = createContext<{
aiEmployees: AIEmployee[];
@ -24,9 +25,11 @@ export const AIEmployeesProvider: React.FC<{
const [aiEmployees, setAIEmployees] = React.useState<AIEmployee[]>(null);
return (
<AISelectionProvider>
<AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}>
<ChatBoxProvider>{props.children}</ChatBoxProvider>
</AIEmployeesContext.Provider>
</AISelectionProvider>
);
};

View File

@ -0,0 +1,85 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Avatar, Divider } from 'antd';
import { useToken } from '@nocobase/client';
import { AIEmployee } from './types';
import { avatars } from './avatars';
import { useT } from '../locale';
export const ProfileCard: React.FC<{
aiEmployee: AIEmployee;
taskDesc?: string;
}> = (props) => {
const { aiEmployee, taskDesc } = props;
const { token } = useToken();
const t = useT();
return (
<div
style={{
width: '300px',
padding: '8px',
}}
>
{aiEmployee ? (
<>
<div
style={{
width: '100%',
textAlign: 'center',
}}
>
<Avatar
src={avatars(aiEmployee.avatar)}
size={60}
style={{
boxShadow: `0px 0px 2px ${token.colorBorder}`,
}}
/>
<div
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
margin: '8px 0',
}}
>
{aiEmployee.nickname}
</div>
</div>
<Divider
orientation="left"
plain
style={{
fontStyle: 'italic',
}}
>
{t('Bio')}
</Divider>
<p>{aiEmployee.bio}</p>
{taskDesc && (
<>
<Divider
orientation="left"
plain
style={{
fontStyle: 'italic',
}}
>
{t('Task description')}
</Divider>
<p>{taskDesc}</p>
</>
)}
</>
) : null}
</div>
);
};

View File

@ -0,0 +1,58 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { List, Popover, Button, Avatar, Divider } from 'antd';
import { useToken } from '@nocobase/client';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { useT } from '../../locale';
import { useChatBoxContext } from './ChatBoxContext';
import { avatars } from '../avatars';
import { css } from '@emotion/css';
import { Sender } from '@ant-design/x';
import { ProfileCard } from '../ProfileCard';
export const AIEmployeeHeader: React.FC = () => {
const t = useT();
const { token } = useToken();
const {
service: { loading },
aiEmployees,
} = useAIEmployeesContext();
const { switchAIEmployee } = useChatBoxContext();
return (
<Sender.Header closable={false}>
<List
loading={loading}
dataSource={aiEmployees || []}
split={false}
itemLayout="horizontal"
renderItem={(aiEmployee) => {
return (
<Popover content={<ProfileCard aiEmployee={aiEmployee} />}>
<Button
className={css`
width: 36px;
height: 36px;
line-height: 36px;
padding: 0;
margin-right: 3px;
`}
shape="circle"
onClick={() => switchAIEmployee(aiEmployee)}
>
<Avatar src={avatars(aiEmployee.avatar)} size={36} />
</Button>
</Popover>
);
}}
/>
</Sender.Header>
);
};

View File

@ -8,77 +8,21 @@
*/
import React, { useContext, useState } from 'react';
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, App } from 'antd';
import { Conversations, Sender, Bubble } from '@ant-design/x';
import type { ConversationsProps } from '@ant-design/x';
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, DeleteOutlined } from '@ant-design/icons';
import { useAPIClient, useToken } from '@nocobase/client';
import { useT } from '../../locale';
import { Layout, Card, Button } from 'antd';
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined } from '@ant-design/icons';
import { useToken } from '@nocobase/client';
const { Header, Footer, Sider, Content } = Layout;
import { avatars } from '../avatars';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { css } from '@emotion/css';
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
import { Attachment } from './Attachment';
import { ChatBoxContext } from './ChatBoxContext';
import { AIEmployee } from '../types';
import { Conversations } from './Conversations';
import { Messages } from './Messages';
import { Sender } from './Sender';
import { useAISelectionContext } from '../selector/AISelectorProvider';
export const ChatBox: React.FC = () => {
const { modal, message } = App.useApp();
const api = useAPIClient();
const {
send,
setOpen,
filterEmployee,
setFilterEmployee,
conversations: conversationsService,
currentConversation,
setCurrentConversation,
messages,
setMessages,
roles,
attachments,
setAttachments,
responseLoading,
senderRef,
senderValue,
setSenderValue,
clear,
} = useContext(ChatBoxContext);
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
const {
aiEmployees,
service: { loading },
} = useAIEmployeesContext();
const t = useT();
const { setOpen, startNewConversation, currentEmployee } = useContext(ChatBoxContext);
const { token } = useToken();
const [showConversations, setShowConversations] = useState(false);
const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])];
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
key: conversation.sessionId,
label: conversation.title,
timestamp: new Date(conversation.updatedAt).getTime(),
}));
const deleteConversation = async (sessionId: string) => {
await api.resource('aiConversations').destroy({
filterByTk: sessionId,
});
message.success(t('Deleted successfully'));
conversationsService.refresh();
clear();
};
const getMessages = async (sessionId: string) => {
const res = await api.resource('aiConversations').getMessages({
sessionId,
});
const messages = res?.data?.data;
if (!messages) {
return;
}
setMessages(messages.reverse());
};
const { selectable } = useAISelectionContext();
return (
<div
@ -90,111 +34,11 @@ export const ChatBox: React.FC = () => {
maxWidth: '760px',
height: '90%',
maxHeight: '560px',
zIndex: 1000,
zIndex: selectable ? -1 : 1000,
}}
>
<Card style={{ height: '100%' }} bodyStyle={{ height: '100%', paddingTop: 0 }}>
<Card style={{ height: '100%' }} styles={{ body: { height: '100%', paddingTop: 0 } }}>
<Layout style={{ height: '100%' }}>
<Sider
width="42px"
style={{
backgroundColor: token.colorBgContainer,
marginRight: '5px',
}}
>
<List
loading={loading}
dataSource={aiEmployeesList}
split={false}
itemLayout="horizontal"
renderItem={(aiEmployee) => {
const highlight =
aiEmployee.username === filterEmployee
? `color: ${token.colorPrimary};
border-color: ${token.colorPrimary};`
: '';
return aiEmployee.username === 'all' ? (
<Button
onClick={() => setFilterEmployee(aiEmployee.username)}
className={css`
width: 40px;
height: 40px;
line-height: 40px;
font-weight: ${token.fontWeightStrong};
margin-top: 10px;
margin-bottom: 8px;
${highlight}
`}
>
ALL
</Button>
) : (
<Popover
placement="rightTop"
content={
<div
style={{
width: '300px',
padding: '8px',
}}
>
<div
style={{
width: '100%',
textAlign: 'center',
}}
>
<Avatar
src={avatars(aiEmployee.avatar)}
size={60}
className={css``}
style={{
boxShadow: `0px 0px 2px ${token.colorBorder}`,
}}
/>
<div
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
margin: '8px 0',
}}
>
{aiEmployee.nickname}
</div>
</div>
<Divider
orientation="left"
plain
style={{
fontStyle: 'italic',
}}
>
{t('Bio')}
</Divider>
<p>{aiEmployee.bio}</p>
</div>
}
>
<Button
className={css`
width: 40px;
height: 40px;
line-height: 40px;
padding: 0;
${highlight}
`}
onClick={() => {
clear();
setFilterEmployee(aiEmployee.username);
}}
>
<Avatar src={avatars(aiEmployee.avatar)} shape="square" size={40} />
</Button>
</Popover>
);
}}
/>
</Sider>
<Sider
width="30%"
style={{
@ -203,57 +47,7 @@ export const ChatBox: React.FC = () => {
marginRight: '5px',
}}
>
<Layout>
<Header
style={{
backgroundColor: token.colorBgContainer,
height: '48px',
lineHeight: '48px',
padding: '0 5px',
}}
>
<Input.Search style={{ verticalAlign: 'middle' }} />
</Header>
<Content>
<Spin spinning={ConversationsLoading}>
{conversations && conversations.length ? (
<Conversations
activeKey={currentConversation}
onActiveChange={(sessionId) => {
if (sessionId === currentConversation) {
return;
}
setCurrentConversation(sessionId);
getMessages(sessionId);
}}
items={conversations}
menu={(conversation) => ({
items: [
{
label: 'Delete',
key: 'delete',
icon: <DeleteOutlined />,
},
],
onClick: ({ key }) => {
switch (key) {
case 'delete':
modal.confirm({
title: t('Delete this conversation?'),
content: t('Are you sure to delete this conversation?'),
onOk: () => deleteConversation(conversation.key),
});
break;
}
},
})}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Spin>
</Content>
</Layout>
<Conversations />
</Sider>
<Layout>
<Header
@ -275,15 +69,7 @@ export const ChatBox: React.FC = () => {
type="text"
onClick={() => setShowConversations(!showConversations)}
/>
{filterEmployee !== 'all' ? (
<Button
icon={<EditOutlined />}
type="text"
onClick={() => {
clear();
}}
/>
) : null}
{currentEmployee ? <Button icon={<EditOutlined />} type="text" onClick={startNewConversation} /> : null}
</div>
<div
style={{
@ -301,27 +87,7 @@ export const ChatBox: React.FC = () => {
position: 'relative',
}}
>
{messages?.length ? (
<Bubble.List
style={{
marginRight: '8px',
}}
roles={roles}
items={messages}
/>
) : (
<div
style={{
position: 'absolute',
width: '64px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<EmptyIcon />
</div>
)}
<Messages />
</Content>
<Footer
style={{
@ -329,61 +95,8 @@ export const ChatBox: React.FC = () => {
padding: 0,
}}
>
<Sender
value={senderValue}
ref={senderRef}
onChange={(value) => {
setSenderValue(value);
}}
onSubmit={(content) =>
send({
sessionId: currentConversation,
aiEmployee: { username: filterEmployee },
messages: [
{
type: 'text',
content,
},
],
})
}
header={
attachments.length ? (
<div
style={{
padding: '8px 8px 0',
}}
>
{attachments.map((attachment, index) => {
return (
<Attachment
key={index}
closeable={true}
onClose={() => {
setAttachments(attachments.filter((_, i) => i !== index));
}}
{...attachment}
/>
);
})}
</div>
) : null
}
disabled={filterEmployee === 'all' && !currentConversation}
placeholder={filterEmployee === 'all' && !currentConversation ? t('Please choose an AI employee.') : ''}
loading={responseLoading}
/>
<Sender />
</Footer>
{/* </Layout> */}
{/* <Sider */}
{/* width="25%" */}
{/* style={{ */}
{/* backgroundColor: token.colorBgContainer, */}
{/* }} */}
{/* > */}
{/* <Conversations items={employees} /> */}
{/* </Sider> */}
{/* </Layout> */}
</Layout>
</Layout>
</Card>

View File

@ -7,10 +7,19 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AttachmentProps, Conversation, Message, Action, SendOptions, AIEmployee } from '../types';
import { Avatar, GetProp, GetRef, Button, Alert, Space } from 'antd';
import {
AttachmentProps,
Conversation,
Message,
Action,
SendOptions,
AIEmployee,
MessageType,
ShortcutOptions,
} from '../types';
import { Avatar, GetProp, GetRef, Button, Alert, Space, Popover } from 'antd';
import type { Sender } from '@ant-design/x';
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import React, { createContext, useContext, useEffect, useState, useRef, useMemo } from 'react';
import { Bubble } from '@ant-design/x';
import { useAPIClient, useRequest } from '@nocobase/client';
import { AIEmployeesContext } from '../AIEmployeesProvider';
@ -18,12 +27,18 @@ import { Attachment } from './Attachment';
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import { avatars } from '../avatars';
import { useChatMessages } from './useChatMessages';
import { uid } from '@formily/shared';
import { useT } from '../../locale';
import { createForm, Form } from '@formily/core';
import { ProfileCard } from '../ProfileCard';
import _ from 'lodash';
import { InfoFormMessage } from './InfoForm';
export const ChatBoxContext = createContext<{
setOpen: (open: boolean) => void;
open: boolean;
filterEmployee: string;
setFilterEmployee: React.Dispatch<React.SetStateAction<string>>;
currentEmployee: AIEmployee;
setCurrentEmployee: React.Dispatch<React.SetStateAction<AIEmployee>>;
conversations: {
loading: boolean;
data?: Conversation[];
@ -35,18 +50,19 @@ export const ChatBoxContext = createContext<{
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
roles: { [role: string]: any };
responseLoading: boolean;
attachments: AttachmentProps[];
setAttachments: React.Dispatch<React.SetStateAction<AttachmentProps[]>>;
actions: Action[];
setActions: React.Dispatch<React.SetStateAction<Action[]>>;
senderValue: string;
setSenderValue: React.Dispatch<React.SetStateAction<string>>;
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
clear: () => void;
senderPlaceholder: string;
infoForm: Form;
showInfoForm: boolean;
switchAIEmployee: (aiEmployee: AIEmployee) => void;
startNewConversation: () => void;
triggerShortcut: (options: ShortcutOptions) => void;
send(opts: SendOptions): void;
}>({} as any);
const defaultRoles = {
const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
user: {
placement: 'end',
styles: {
@ -81,6 +97,24 @@ const defaultRoles = {
);
},
},
info: {
placement: 'start',
avatar: { icon: '', style: { visibility: 'hidden' } },
typing: { step: 5, interval: 20 },
style: {
maxWidth: 400,
marginInlineEnd: 48,
},
styles: {
footer: {
width: '100%',
},
},
// variant: 'borderless',
messageRender: (msg: any) => {
return <InfoFormMessage values={msg.content} />;
},
},
action: {
placement: 'start',
avatar: { icon: '', style: { visibility: 'hidden' } },
@ -107,7 +141,11 @@ const defaultRoles = {
const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
placement: 'start',
avatar: aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} /> : null,
avatar: aiEmployee.avatar ? (
<Popover content={<ProfileCard aiEmployee={aiEmployee} />} placement="leftTop">
<Avatar src={avatars(aiEmployee.avatar)} />
</Popover>
) : null,
typing: { step: 5, interval: 20 },
style: {
maxWidth: 400,
@ -139,63 +177,123 @@ const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
},
});
export const useChatBoxContext = () => {
export const useSetChatBoxContext = () => {
const t = useT();
const api = useAPIClient();
const { aiEmployees } = useContext(AIEmployeesContext);
const [openChatBox, setOpenChatBox] = useState(false);
const [filterEmployee, setFilterEmployee] = useState('all');
const [open, setOpen] = useState(false);
const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null);
const [currentConversation, setCurrentConversation] = useState<string>();
const { messages, setMessages, attachments, setAttachments, actions, setActions, responseLoading, sendMessages } =
useChatMessages();
const { messages, setMessages, responseLoading, addMessage, sendMessages } = useChatMessages();
const [senderValue, setSenderValue] = useState<string>('');
const [senderPlaceholder, setSenderPlaceholder] = useState<string>('');
const senderRef = useRef<GetRef<typeof Sender>>(null);
const [roles, setRoles] = useState<GetProp<typeof Bubble.List, 'roles'>>(defaultRoles);
const infoForm = useMemo(() => createForm(), []);
const conversations = useRequest<Conversation[]>(
() =>
api
.resource('aiConversations')
.list({
sort: ['-updatedAt'],
...(filterEmployee !== 'all'
? {
filter: {
'aiEmployees.username': filterEmployee,
},
}
: {}),
})
.then((res) => res?.data?.data),
{
ready: openChatBox,
refreshDeps: [filterEmployee],
ready: open,
},
);
const clear = () => {
setCurrentConversation(undefined);
setMessages([]);
setAttachments([]);
setActions([]);
const send = (options: SendOptions) => {
const sendOptions = {
...options,
onConversationCreate: (sessionId: string) => {
setCurrentConversation(sessionId);
conversations.refresh();
},
};
if (!_.isEmpty(infoForm?.values)) {
sendOptions.infoFormValues = { ...infoForm.values };
}
setSenderValue('');
senderRef.current?.focus();
infoForm.reset();
sendMessages(sendOptions);
};
const send = async (options: SendOptions) => {
setSenderValue('');
const { aiEmployee } = options;
const updateRole = (aiEmployee: AIEmployee) => {
if (!roles[aiEmployee.username]) {
setRoles((prev) => ({
...prev,
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
}));
}
sendMessages({
...options,
onConversationCreate: (sessionId: string) => {
setCurrentConversation(sessionId);
conversations.refresh();
};
const switchAIEmployee = (aiEmployee: AIEmployee) => {
const greetingMsg = {
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
};
setCurrentEmployee(aiEmployee);
setSenderPlaceholder(aiEmployee.chatSettings?.senderPlaceholder);
infoForm.reset();
senderRef.current?.focus();
if (!currentConversation) {
setMessages([greetingMsg]);
} else {
addMessage(greetingMsg);
setSenderValue('');
}
};
const startNewConversation = () => {
setCurrentConversation(undefined);
setCurrentEmployee(null);
setSenderValue('');
infoForm.reset();
setMessages([]);
senderRef.current?.focus();
};
const triggerShortcut = (options: ShortcutOptions) => {
const { aiEmployee, message, infoFormValues, autoSend } = options;
updateRole(aiEmployee);
if (!open) {
setOpen(true);
}
if (currentConversation) {
setCurrentConversation(undefined);
setMessages([]);
}
setCurrentEmployee(aiEmployee);
if (message && message.type === 'text') {
setSenderValue(message.content);
} else {
setSenderValue('');
}
setMessages([
{
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
},
]);
senderRef.current?.focus();
infoForm.setValues(infoFormValues);
if (autoSend) {
send({
aiEmployee,
messages: [message],
});
}
};
useEffect(() => {
@ -215,16 +313,16 @@ export const useChatBoxContext = () => {
}, [aiEmployees]);
useEffect(() => {
if (openChatBox) {
if (open) {
senderRef.current?.focus();
}
}, [openChatBox]);
}, [open]);
return {
open: openChatBox,
setOpen: setOpenChatBox,
filterEmployee,
setFilterEmployee,
open,
setOpen,
currentEmployee,
setCurrentEmployee,
conversations,
currentConversation,
setCurrentConversation,
@ -232,14 +330,19 @@ export const useChatBoxContext = () => {
setMessages,
roles,
responseLoading,
attachments,
setAttachments,
actions,
setActions,
senderRef,
senderValue,
setSenderValue,
senderPlaceholder,
showInfoForm: !!currentEmployee?.chatSettings?.infoForm?.length,
infoForm,
switchAIEmployee,
startNewConversation,
triggerShortcut,
send,
clear,
};
};
export const useChatBoxContext = () => {
return useContext(ChatBoxContext);
};

View File

@ -13,13 +13,13 @@ import { CurrentUserContext } from '@nocobase/client';
import { ChatBox } from './ChatBox';
import icon from '../icon.svg';
import { css } from '@emotion/css';
import { ChatBoxContext, useChatBoxContext } from './ChatBoxContext';
import { ChatBoxContext, useSetChatBoxContext } from './ChatBoxContext';
export const ChatBoxProvider: React.FC<{
children: React.ReactNode;
}> = (props) => {
const currentUserCtx = useContext(CurrentUserContext);
const chatBoxCtx = useChatBoxContext();
const chatBoxCtx = useSetChatBoxContext();
const { open, setOpen } = chatBoxCtx;
if (!currentUserCtx?.data?.data) {

View File

@ -0,0 +1,112 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Layout, Input, Empty, Spin, App } from 'antd';
import { Conversations as AntConversations } from '@ant-design/x';
import type { ConversationsProps } from '@ant-design/x';
import { useAPIClient, useToken } from '@nocobase/client';
import { useChatBoxContext } from './ChatBoxContext';
import { useT } from '../../locale';
import { DeleteOutlined } from '@ant-design/icons';
const { Header, Content } = Layout;
export const Conversations: React.FC = () => {
const t = useT();
const api = useAPIClient();
const { modal, message } = App.useApp();
const { token } = useToken();
const {
conversations: conversationsService,
currentConversation,
setCurrentConversation,
setMessages,
startNewConversation,
} = useChatBoxContext();
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
key: conversation.sessionId,
label: conversation.title,
timestamp: new Date(conversation.updatedAt).getTime(),
}));
const deleteConversation = async (sessionId: string) => {
await api.resource('aiConversations').destroy({
filterByTk: sessionId,
});
message.success(t('Deleted successfully'));
conversationsService.refresh();
startNewConversation();
};
const getMessages = async (sessionId: string) => {
const res = await api.resource('aiConversations').getMessages({
sessionId,
});
const messages = res?.data?.data;
if (!messages) {
return;
}
setMessages(messages.reverse());
};
return (
<Layout>
<Header
style={{
backgroundColor: token.colorBgContainer,
height: '48px',
lineHeight: '48px',
padding: '0 5px',
}}
>
<Input.Search style={{ verticalAlign: 'middle' }} />
</Header>
<Content>
<Spin spinning={ConversationsLoading}>
{conversations && conversations.length ? (
<AntConversations
activeKey={currentConversation}
onActiveChange={(sessionId) => {
if (sessionId === currentConversation) {
return;
}
setCurrentConversation(sessionId);
getMessages(sessionId);
}}
items={conversations}
menu={(conversation) => ({
items: [
{
label: 'Delete',
key: 'delete',
icon: <DeleteOutlined />,
},
],
onClick: ({ key }) => {
switch (key) {
case 'delete':
modal.confirm({
title: t('Delete this conversation?'),
content: t('Are you sure to delete this conversation?'),
onOk: () => deleteConversation(conversation.key),
});
break;
}
},
})}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Spin>
</Content>
</Layout>
);
};

View File

@ -0,0 +1,33 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Button } from 'antd';
import { SelectOutlined } from '@ant-design/icons';
import { useChatBoxContext } from './ChatBoxContext';
import { useAISelectionContext } from '../selector/AISelectorProvider';
export const FieldSelector: React.FC = () => {
const { currentEmployee, senderValue, setSenderValue, senderRef } = useChatBoxContext();
const { startSelect } = useAISelectionContext();
const handleSelect = () => {
startSelect('fields', {
onSelect: ({ value }) => {
if (!value) {
return;
}
setSenderValue(senderValue + value);
senderRef.current?.focus();
},
});
};
return <Button disabled={!currentEmployee} type="text" icon={<SelectOutlined />} onClick={handleSelect} />;
};

View File

@ -0,0 +1,125 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import { AIEmployee } from '../types';
import { SchemaComponent } from '@nocobase/client';
import { BlockSelector } from '../selector/BlockSelector';
import { useChatBoxContext } from './ChatBoxContext';
import { useT } from '../../locale';
import { uid } from '@formily/shared';
import { createForm } from '@formily/core';
import { Card } from 'antd';
const schemaMap = {
blocks: {
'x-component': 'BlockSelector',
'x-component-props': {},
},
collections: {
'x-component': 'CollectionSelect',
'x-component-props': {
multiple: true,
},
},
};
export const InfoForm: React.FC<{
aiEmployee: AIEmployee;
}> = ({ aiEmployee }) => {
const infoForm = aiEmployee?.chatSettings?.infoForm || [];
const schemaProperties = infoForm.map((field) => ({
name: field.name,
title: field.title,
type: 'string',
'x-decorator': 'FormItem',
...schemaMap[field.type],
}));
return (
<SchemaComponent
components={{ BlockSelector }}
schema={{
type: 'void',
properties: schemaProperties.reduce((acc, field) => {
acc[field.name] = field;
return acc;
}, {}),
}}
/>
);
};
export const ReadPrettyInfoForm: React.FC<{
aiEmployee: AIEmployee;
}> = ({ aiEmployee }) => {
const infoForm = aiEmployee?.chatSettings?.infoForm || [];
const schemaProperties = infoForm.map((field) => ({
name: field.name,
title: field.title,
type: 'string',
'x-decorator': 'FormItem',
'x-decorator-props': {
style: {
marginBottom: '5px',
},
},
'x-component': 'Select',
'x-read-pretty': true,
}));
return (
<SchemaComponent
schema={{
type: 'void',
properties: schemaProperties.reduce((acc, field) => {
acc[field.name] = field;
return acc;
}, {}),
}}
/>
);
};
export const InfoFormMessage: React.FC<{
values: any;
}> = ({ values }) => {
const { currentEmployee } = useChatBoxContext();
const t = useT();
const form = useMemo(
() =>
createForm({
initialValues: values,
}),
[values],
);
return (
<>
{t('I will use the following information')}
<SchemaComponent
components={{ ReadPrettyInfoForm }}
schema={{
type: 'void',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
layout: 'horizontal',
},
'x-component': 'ReadPrettyInfoForm',
'x-component-props': {
aiEmployee: currentEmployee,
},
},
},
}}
/>
</>
);
};

View File

@ -0,0 +1,42 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Bubble } from '@ant-design/x';
import { useChatBoxContext } from './ChatBoxContext';
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
export const Messages: React.FC = () => {
const { messages, roles } = useChatBoxContext();
return (
<>
{messages?.length ? (
<Bubble.List
style={{
marginRight: '8px',
}}
roles={roles}
items={messages}
/>
) : (
<div
style={{
position: 'absolute',
width: '64px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<EmptyIcon />
</div>
)}
</>
);
};

View File

@ -0,0 +1,61 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { memo } from 'react';
import { Sender as AntSender } from '@ant-design/x';
import { useChatBoxContext } from './ChatBoxContext';
import { SenderPrefix } from './SenderPrefix';
import { Attachment } from './Attachment';
import { AIEmployeeHeader } from './AIEmployeeHeader';
import { useT } from '../../locale';
import { SenderHeader } from './SenderHeader';
import { SenderFooter } from './SenderFooter';
export const Sender: React.FC = memo(() => {
const t = useT();
const {
senderValue,
setSenderValue,
senderPlaceholder,
send,
currentConversation,
currentEmployee,
responseLoading,
showInfoForm,
senderRef,
} = useChatBoxContext();
return (
<AntSender
value={senderValue}
ref={senderRef}
onChange={(value) => {
setSenderValue(value);
}}
onSubmit={(content) =>
send({
sessionId: currentConversation,
aiEmployee: currentEmployee,
messages: [
{
type: 'text',
content,
},
],
})
}
prefix={currentConversation || !showInfoForm ? <SenderPrefix /> : null}
header={!currentConversation ? <SenderHeader /> : null}
loading={responseLoading}
footer={({ components }) => <SenderFooter components={components} />}
disabled={!currentEmployee}
placeholder={!currentEmployee ? t('Please choose an AI employee') : senderPlaceholder}
actions={false}
/>
);
});

View File

@ -0,0 +1,34 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Divider, Flex } from 'antd';
import { useChatBoxContext } from './ChatBoxContext';
import { FieldSelector } from './FieldSelector';
import { useT } from '../../locale';
export const SenderFooter: React.FC<{
components: any;
}> = ({ components }) => {
const t = useT();
const { SendButton, LoadingButton } = components;
const { responseLoading: loading } = useChatBoxContext();
return (
<Flex justify="space-between" align="center">
<Flex gap="small" align="center">
<FieldSelector />
<Divider type="vertical" />
</Flex>
<Flex align="center">
{loading ? <LoadingButton type="default" /> : <SendButton type="primary" disabled={false} />}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,78 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import { useChatBoxContext } from './ChatBoxContext';
import { Sender } from '@ant-design/x';
import { AIEmployeeHeader } from './AIEmployeeHeader';
import { Avatar } from 'antd';
import { avatars } from '../avatars';
import { useT } from '../../locale';
import { SchemaComponent, useToken } from '@nocobase/client';
import { InfoForm } from './InfoForm';
import { uid } from '@formily/shared';
export const SenderHeader: React.FC = () => {
const t = useT();
const { token } = useToken();
const { currentEmployee, startNewConversation, showInfoForm, infoForm: form } = useChatBoxContext();
return currentEmployee ? (
showInfoForm ? (
<Sender.Header
onOpenChange={startNewConversation}
title={
<div
style={{
display: 'flex',
alignItems: 'flex-start',
}}
>
<Avatar src={avatars(currentEmployee.avatar)} />
<div
style={{
marginLeft: '4px',
paddingTop: '4px',
}}
>
<div
style={{
color: token.colorTextDisabled,
}}
>
{t('Please tell me the following information')}
</div>
</div>
</div>
}
>
<SchemaComponent
components={{ InfoForm }}
schema={{
type: 'void',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
},
'x-component': 'InfoForm',
'x-component-props': {
aiEmployee: currentEmployee,
},
},
},
}}
/>
</Sender.Header>
) : null
) : (
<AIEmployeeHeader />
);
};

View File

@ -0,0 +1,25 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useContext } from 'react';
import { ChatBoxContext } from './ChatBoxContext';
import { avatars } from '../avatars';
import { Avatar } from 'antd';
export const SenderPrefix: React.FC = () => {
const { currentEmployee } = useContext(ChatBoxContext);
return currentEmployee ? (
<Avatar
src={avatars(currentEmployee.avatar)}
style={{
cursor: 'pointer',
}}
/>
) : null;
};

View File

@ -18,14 +18,6 @@ export const useChatMessages = () => {
const api = useAPIClient();
const [messages, setMessages] = useState<Message[]>([]);
const [responseLoading, setResponseLoading] = useState(false);
const [attachments, setAttachments] = useState<AttachmentProps[]>([]);
const [actions, setActions] = useState<Action[]>([]);
const clearMessages = () => {
setMessages([]);
setAttachments([]);
setActions([]);
};
const addMessage = (message: Message) => {
setMessages((prev) => [...prev, message]);
@ -108,34 +100,24 @@ export const useChatMessages = () => {
sessionId,
aiEmployee,
messages: sendMsgs,
greeting,
infoFormValues,
onConversationCreate,
}: SendOptions & {
onConversationCreate?: (sessionId: string) => void;
}) => {
const msgs: Message[] = [];
if (greeting) {
msgs.push({
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
});
}
if (!sendMsgs.length) {
addMessages(msgs);
return;
}
if (attachments.length) {
msgs.push(
...attachments.map((attachment) => ({
if (infoFormValues) {
msgs.push({
key: uid(),
role: 'user',
content: attachment,
})),
);
role: 'info',
content: {
type: 'info',
content: infoFormValues,
},
});
}
msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg })));
addMessages(msgs);
@ -152,7 +134,6 @@ export const useChatMessages = () => {
sessionId = conversation.sessionId;
onConversationCreate?.(sessionId);
}
setAttachments([]);
setResponseLoading(true);
addMessage({
@ -183,34 +164,31 @@ export const useChatMessages = () => {
return;
}
const { result, error } = await processStreamResponse(sendRes.data);
if (actions.length && !error) {
addMessages(
actions.map((action) => ({
key: uid(),
role: 'action',
content: {
type: 'action',
icon: action.icon,
content: action.content,
onClick: () => {
action.onClick(result);
},
},
})),
);
setActions([]);
}
// if (actions.length && !error) {
// addMessages(
// actions.map((action) => ({
// key: uid(),
// role: 'action',
// content: {
// type: 'action',
// icon: action.icon,
// content: action.content,
// onClick: () => {
// action.onClick(result);
// },
// },
// })),
// );
// setActions([]);
// }
};
return {
messages,
addMessage,
addMessages,
setMessages,
attachments,
setAttachments,
actions,
setActions,
responseLoading,
sendMessages,
clearMessages,
};
};

View File

@ -7,24 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useContext } from 'react';
import { Avatar, Tag, Popover, Divider, Button } from 'antd';
import React from 'react';
import { Avatar, Popover, Button } from 'antd';
import { avatars } from '../avatars';
import {
SortableItem,
useBlockContext,
useLocalVariables,
useSchemaToolbarRender,
useToken,
useVariables,
withDynamicSchemaProps,
} from '@nocobase/client';
import { useFieldSchema } from '@formily/react';
import { useT } from '../../locale';
import { css } from '@emotion/css';
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
import { ChatBoxContext } from '../chatbox/ChatBoxContext';
import { useChatBoxContext } from '../chatbox/ChatBoxContext';
import { AIEmployee } from '../types';
import { ProfileCard } from '../ProfileCard';
async function replaceVariables(template, variables, localVariables = {}) {
const regex = /\{\{\s*(.*?)\s*\}\}/g;
@ -64,17 +60,18 @@ async function replaceVariables(template, variables, localVariables = {}) {
export const AIEmployeeButton: React.FC<{
aiEmployee: AIEmployee;
taskDesc?: string;
attachments: string[];
actions: string[];
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, attachments: selectedAttachments, actions: selectedActions }) => {
const t = useT();
const { setOpen, send, setAttachments, setFilterEmployee, setActions, clear } = useContext(ChatBoxContext);
const { token } = useToken();
autoSend?: boolean;
message: {
type: string;
content: string;
};
infoForm: any;
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, message, infoForm: infoFormValues, autoSend }) => {
const { triggerShortcut } = useChatBoxContext();
const fieldSchema = useFieldSchema();
const { render } = useSchemaToolbarRender(fieldSchema);
const variables = useVariables();
const localVariables = useLocalVariables();
const { attachments, actions } = useAIEmployeeChatContext();
return (
<SortableItem
@ -82,110 +79,23 @@ export const AIEmployeeButton: React.FC<{
position: 'relative',
}}
onClick={async () => {
clear();
setOpen(true);
setFilterEmployee(aiEmployee.username);
if (selectedAttachments && selectedAttachments.length) {
setAttachments((prev) => {
const newAttachments = selectedAttachments.map((name: string) => {
const attachment = attachments[name];
return {
type: attachment.type,
title: attachment.title,
content: attachment.content,
};
});
return [...prev, ...newAttachments];
});
}
if (selectedActions && selectedActions.length) {
setActions((prev) => {
const newActions = selectedActions.map((name: string) => {
const action = actions[name];
return {
icon: action.icon,
content: action.title,
onClick: action.action,
};
});
return [...prev, ...newActions];
});
}
const messages = [];
const message = fieldSchema['x-component-props']?.message;
if (message) {
let msg;
if (message && message.content) {
const content = await replaceVariables(message.content, variables, localVariables);
messages.push({
msg = {
type: message.type || 'text',
content,
});
};
}
send({
triggerShortcut({
aiEmployee,
messages,
greeting: true,
message: msg,
autoSend,
infoFormValues,
});
}}
>
<Popover
content={
<div
style={{
width: '300px',
padding: '8px',
}}
>
<div
style={{
width: '100%',
textAlign: 'center',
}}
>
<Avatar
src={avatars(aiEmployee.avatar)}
size={60}
className={css``}
style={{
boxShadow: `0px 0px 2px ${token.colorBorder}`,
}}
/>
<div
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
margin: '8px 0',
}}
>
{aiEmployee.nickname}
</div>
</div>
<Divider
orientation="left"
plain
style={{
fontStyle: 'italic',
}}
>
{t('Bio')}
</Divider>
<p>{aiEmployee.bio}</p>
{taskDesc && (
<>
<Divider
orientation="left"
plain
style={{
fontStyle: 'italic',
}}
>
{t('Task description')}
</Divider>
<p>{taskDesc}</p>
</>
)}
</div>
}
>
<Popover content={<ProfileCard taskDesc={taskDesc} aiEmployee={aiEmployee} />}>
<Button
shape="circle"
style={{

View File

@ -0,0 +1,116 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
import { useT } from '../../locale';
import { InputsFormSettings } from './InputsFormSettings';
import { ArrayItems } from '@formily/antd-v5';
export const ChatSettings: React.FC = () => {
const t = useT();
return (
<SchemaComponent
components={{ InputsFormSettings, ArrayItems }}
schema={{
type: 'object',
name: 'chatSettings',
properties: {
newConversationSettings: {
type: 'void',
'x-component': 'Divider',
'x-component-props': {
children: t('New conversation settings'),
},
},
senderPlaceholder: {
type: 'string',
'x-component': 'Input',
'x-decorator': 'FormItem',
title: t('Sender placeholder'),
},
infoForm: {
type: 'array',
title: t('Required information form'),
description: t(
'Provide a form for the user to fill in the required information when starting a new conversation',
),
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
sort: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.SortHandle',
},
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: t('Field name'),
},
required: true,
},
title: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: t('Field title'),
},
required: true,
},
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: t('Field type'),
},
required: true,
enum: [
{
label: t('Blocks'),
value: 'blocks',
},
{
label: t('Collections'),
value: 'collections',
},
],
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: t('Add field'),
'x-component': 'ArrayItems.Addition',
},
},
},
},
}}
/>
);
};

View File

@ -22,12 +22,13 @@ import { useT } from '../../locale';
const { Meta } = Card;
import { css } from '@emotion/css';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { AvatarSelect } from './AvatarSelect';
import { useForm } from '@formily/react';
import { createForm } from '@formily/core';
import { uid } from '@formily/shared';
import { avatars } from '../avatars';
import { ModelSettings } from './ModelSettings';
import { ProfileSettings } from './ProfileSettings';
import { ChatSettings } from './ChatSettings';
const EmployeeContext = createContext(null);
@ -38,63 +39,12 @@ const AIEmployeeForm: React.FC = () => {
{
key: 'profile',
label: 'Profile',
children: (
<SchemaComponent
components={{ AvatarSelect }}
schema={{
type: 'void',
properties: {
username: {
type: 'string',
title: 'Username',
'x-decorator': 'FormItem',
'x-component': 'Input',
children: <ProfileSettings />,
},
nickname: {
type: 'string',
title: 'Nickname',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
avatar: {
type: 'string',
title: 'Avatar',
'x-decorator': 'FormItem',
'x-component': 'AvatarSelect',
},
bio: {
type: 'string',
title: 'Bio',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
placeholder:
'The introduction to the AI employee will inform human colleagues about its skills and how to use it. This information will be displayed on the employees profile. This will not be part of the prompt of this AI employee.',
},
},
about: {
type: 'string',
title: 'About me',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
placeholder:
'Define the AI employees role, guide its work, and instruct it complete user-assigned tasks. This will be part of the prompt of this AI employee.',
autoSize: {
minRows: 15,
},
},
},
greeting: {
type: 'string',
title: 'Greeting message',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
}}
/>
),
{
key: 'chat',
label: 'Chat settings',
children: <ChatSettings />,
},
// {
// key: 'skills',
@ -178,41 +128,7 @@ const AIEmployeeForm: React.FC = () => {
{
key: 'modelSettings',
label: 'Model Settings',
children: (
<SchemaComponent
components={{ ModelSettings }}
schema={{
type: 'object',
name: 'modelSettings',
properties: {
llmService: {
type: 'string',
title: 'LLM service',
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
service: {
resource: 'llmServices',
action: 'list',
params: {
fields: ['title', 'name'],
},
},
},
},
settings: {
type: 'void',
'x-component': 'ModelSettings',
},
},
}}
/>
),
children: <ModelSettings />,
},
]}
/>

View File

@ -0,0 +1,242 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useState } from 'react';
import { Card, Button, Empty, Modal, Collapse, Form, Input, Switch, Select, List, Tag } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useT } from '../../locale';
import { useField } from '@formily/react';
import { ArrayField } from '@formily/core';
import { FormItem, FormLayout } from '@formily/antd-v5';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
const inputSourceLabels = {
manual: 'Manual input',
blocks: 'Blocks',
fields: 'Field values',
collections: 'Data sources & collections',
};
const InputsEditModal: React.FC<{
title: string;
open: boolean;
onOk: (value: any) => void;
onCancel: () => void;
}> = ({ open, title, onOk, onCancel }) => {
const t = useT();
const [inputField, setInputField] = useState({
title: '',
sources: {
manual: {
enabled: true,
value: '',
},
blocks: {
enabled: false,
value: [],
},
fields: {
enabled: false,
value: [],
},
collections: {
enabled: false,
value: [],
},
},
});
const handleTitleChange = (value: string) => {
setInputField((prev) => ({
...prev,
title: value,
}));
};
const handleSwitchChange = (key: string, value: boolean) => {
setInputField((prev) => ({
...prev,
sources: {
...prev.sources,
[key]: {
...prev.sources[key],
enabled: value,
},
},
}));
};
const handleValueChange = (key: string, value: any) => {
setInputField((prev) => ({
...prev,
sources: {
...prev.sources,
[key]: {
...prev.sources[key],
value,
},
},
}));
};
return (
<Modal open={open} title={title} onOk={() => onOk(inputField)} onCancel={onCancel}>
<FormLayout layout="vertical">
<FormItem label={t('Title')}>
<Input value={inputField.title} onChange={(e) => handleTitleChange(e.target.value)} />
</FormItem>
<FormItem label={t('Input sources')}>
<Collapse
size="small"
items={[
{
key: 'manual',
label: t('Manual input'),
children: (
<Form.Item>
<Input placeholder={t('Placeholder')} onChange={(v) => handleValueChange('manual', v)} />
</Form.Item>
),
extra: <Switch size="small" defaultChecked onChange={(v) => handleSwitchChange('manual', v)} />,
},
{
key: 'blocks',
label: t('Blocks'),
children: (
<Form.Item>
<Select
allowClear
mode="multiple"
options={[
{
key: 'table',
value: 'table',
label: t('Table'),
},
{
key: 'form',
value: 'form',
label: t('Form'),
},
]}
onChange={(v) => handleValueChange('blocks', v)}
/>
</Form.Item>
),
extra: <Switch size="small" onChange={(v) => handleSwitchChange('blocks', v)} />,
},
{
key: 'fields',
label: t('Field values'),
children: (
<Form.Item>
<Select
mode="multiple"
allowClear
options={[
{
key: 'input',
value: 'input',
label: t('Single line text'),
},
]}
onChange={(v) => handleValueChange('fields', v)}
/>
</Form.Item>
),
extra: <Switch size="small" onChange={(v) => handleSwitchChange('fields', v)} />,
},
{
key: 'collections',
label: t('Data sources & collections'),
children: (
<Form.Item>
<Select onChange={(v) => handleValueChange('collections', v)} />
</Form.Item>
),
extra: <Switch size="small" onChange={(v) => handleSwitchChange('collections', v)} />,
},
]}
/>
</FormItem>
</FormLayout>
</Modal>
);
};
export const InputsFormSettings: React.FC = () => {
const t = useT();
const field = useField<ArrayField>();
const [open, setOpen] = React.useState(false);
const handleAdd = (value: any) => {
const enabledSources = {};
for (const key in value.sources) {
if (value.sources[key].enabled) {
enabledSources[key] = value.sources[key];
}
}
value.sources = enabledSources;
field.value = [...(field.value || []), value];
setOpen(false);
};
return (
<Card
styles={{
body: {
padding: 0,
},
}}
extra={
<>
<Button size="small" variant="dashed" color="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
{t('Add field')}
</Button>
<InputsEditModal title={t('Add field')} open={open} onOk={handleAdd} onCancel={() => setOpen(false)} />
</>
}
>
{field.value && field.value.length ? (
<List
size="small"
dataSource={field.value}
renderItem={(item) => {
return (
<List.Item
actions={[
<Button key="edit" icon={<EditOutlined />} type="text" />,
<Button key="delete" icon={<DeleteOutlined />} type="text" />,
]}
>
<List.Item.Meta title={item.title} />
<>
{Object.keys(item.sources || {}).map((source) => {
return (
<Tag
style={{
marginBottom: '3px',
}}
key={source}
>
{t(inputSourceLabels[source])}
</Tag>
);
})}
</>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
);
};

View File

@ -8,17 +8,17 @@
*/
import { observer, useForm } from '@formily/react';
import { useAPIClient, usePlugin, useRequest } from '@nocobase/client';
import { SchemaComponent, useAPIClient, usePlugin, useRequest } from '@nocobase/client';
import React from 'react';
import PluginAIClient from '../../';
export const useModelSettingsForm = (provider: string) => {
const useModelOptionsForm = (provider: string) => {
const plugin = usePlugin(PluginAIClient);
const p = plugin.aiManager.llmProviders.get(provider);
return p?.components?.ModelSettingsForm;
};
export const ModelSettings = observer(
const ModelOptions = observer(
() => {
const form = useForm();
const api = useAPIClient();
@ -33,11 +33,49 @@ export const ModelSettings = observer(
refreshDeps: [form.values?.modelSettings?.llmService],
},
);
const Component = useModelSettingsForm(data?.provider);
const Component = useModelOptionsForm(data?.provider);
if (loading) {
return null;
}
return Component ? <Component /> : null;
},
{ displayName: 'AIEmployeeModelSettingsForm' },
{ displayName: 'AIEmployeeModelOptionsForm' },
);
export const ModelSettings: React.FC = () => {
return (
<SchemaComponent
components={{ ModelOptions }}
schema={{
type: 'object',
name: 'modelSettings',
properties: {
llmService: {
type: 'string',
title: 'LLM service',
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
service: {
resource: 'llmServices',
action: 'list',
params: {
fields: ['title', 'name'],
},
},
},
},
settings: {
type: 'void',
'x-component': 'ModelOptions',
},
},
}}
/>
);
};

View File

@ -0,0 +1,72 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { AvatarSelect } from './AvatarSelect';
export const ProfileSettings: React.FC = () => {
return (
<SchemaComponent
components={{ AvatarSelect }}
schema={{
type: 'void',
properties: {
username: {
type: 'string',
title: 'Username',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
nickname: {
type: 'string',
title: 'Nickname',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
avatar: {
type: 'string',
title: 'Avatar',
'x-decorator': 'FormItem',
'x-component': 'AvatarSelect',
},
bio: {
type: 'string',
title: 'Bio',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
placeholder:
'The introduction to the AI employee will inform human colleagues about its skills and how to use it. This information will be displayed on the employees profile. This will not be part of the prompt of this AI employee.',
},
},
about: {
type: 'string',
title: 'About me',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
placeholder:
'Define the AI employees role, guide its work, and instruct it complete user-assigned tasks. This will be part of the prompt of this AI employee.',
autoSize: {
minRows: 15,
},
},
},
greeting: {
type: 'string',
title: 'Greeting message',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
}}
/>
);
};

View File

@ -0,0 +1,47 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { createContext, useContext, useState } from 'react';
import { Selector } from '../types';
export const AISelectionContext = createContext<{
selectable: string;
selector?: Selector;
startSelect: (selectType: string, selector?: Selector) => void;
stopSelect: () => void;
}>({
selectable: false,
} as any);
export const useAISelectionContext = () => {
return useContext(AISelectionContext);
};
export const AISelectionProvider: React.FC = (props) => {
const [selectable, setSelectable] = useState('');
const [selector, setSelector] = useState<Selector>(null);
const startSelect = (selectType: string, selector?: Selector) => {
if (selector) {
setSelector(selector);
}
setSelectable(selectType);
};
const stopSelect = () => {
setSelectable('');
setSelector(null);
};
return (
<AISelectionContext.Provider value={{ selectable, selector, startSelect, stopSelect }}>
{props.children}
</AISelectionContext.Provider>
);
};

View File

@ -0,0 +1,68 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Button } from 'antd';
import { useT } from '../../locale';
import { BuildOutlined } from '@ant-design/icons';
import { useAISelectionContext } from './AISelectorProvider';
import { useField } from '@formily/react';
import { Field } from '@formily/core';
import { CloseCircleOutlined, PauseOutlined } from '@ant-design/icons';
export const BlockSelector: React.FC<{
onSelect?: (ctx: { uid: string }) => void;
}> = ({ onSelect }) => {
const t = useT();
const { startSelect, stopSelect } = useAISelectionContext();
const [selecting, setSelecting] = React.useState(false);
const field = useField<Field>();
return (
<Button
variant="dashed"
color="primary"
icon={<BuildOutlined />}
size="small"
onClick={() => {
if (selecting) {
setSelecting(false);
stopSelect();
return;
}
setSelecting(true);
startSelect('blocks', {
onSelect: (ctx) => {
onSelect?.(ctx);
field.value = ctx.uid;
setSelecting(false);
},
});
}}
>
{selecting ? (
<>
{t('Selecting...')} <PauseOutlined />
</>
) : field.value ? (
<>
{field.value}{' '}
<CloseCircleOutlined
onClick={(e) => {
e.stopPropagation();
field.value = null;
}}
/>
</>
) : (
t('Select block')
)}
</Button>
);
};

View File

@ -0,0 +1,71 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { createStyles } from '@nocobase/client';
import React, { ComponentType, forwardRef } from 'react';
import { useAISelectionContext } from './AISelectorProvider';
import { useFieldSchema, useField } from '@formily/react';
const useStyles = createStyles(({ token, css }) => {
return {
aiSelectable: css`
position: relative;
transition: all 0.3s ease;
&:hover {
cursor: grab;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 210, 255, 0.2);
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
&:hover::after {
opacity: 1;
}
`,
};
});
export function withAISelectable<T = any>(
WrappedComponent: React.ComponentType,
options: {
selectType: string;
},
) {
const { selectType } = options;
const SelectableComponent: ComponentType<T> = (props) => {
const { styles } = useStyles();
const { selectable, selector, stopSelect } = useAISelectionContext();
const fieldSchema = useFieldSchema();
const field = useField();
const handleSelect = () => {
selector?.onSelect?.({
uid: fieldSchema['x-uid'],
value: field?.value,
});
stopSelect();
};
return (
<div className={selectable === selectType ? styles.aiSelectable : ''} onClick={handleSelect}>
<WrappedComponent {...props} />
</div>
);
};
return SelectableComponent;
}

View File

@ -7,10 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import {
SchemaComponent,
SchemaSettings,
SchemaSettingsModalItem,
SchemaSettingsItem,
useBlockContext,
useCollection,
useCollectionFilterOptions,
@ -19,10 +20,14 @@ import {
} from '@nocobase/client';
import { useT } from '../../locale';
import { avatars } from '../avatars';
import { Card, Avatar, Tooltip } from 'antd';
import { Card, Avatar, Tooltip, Modal } from 'antd';
const { Meta } = Card;
import { Schema } from '@formily/react';
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
import { createForm } from '@formily/core';
import { InfoForm } from '../chatbox/InfoForm';
import { uid } from '@formily/shared';
import { useAISelectionContext } from '../selector/AISelectorProvider';
import { AIEmployee } from '../types';
export const useAIEmployeeButtonVariableOptions = () => {
const collection = useCollection();
@ -49,46 +54,24 @@ export const useAIEmployeeButtonVariableOptions = () => {
}, [recordData, t, fields, blockType]);
};
export const aiEmployeeButtonSettings = new SchemaSettings({
name: 'aiEmployees:button',
items: [
{
name: 'edit',
Component: () => {
const t = useT();
const SettingsForm: React.FC<{
form: any;
aiEmployee: AIEmployee;
}> = memo(({ form, aiEmployee }) => {
const { dn } = useSchemaSettings();
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
const { attachments = [], actions = [] } = useAIEmployeeChatContext();
const attachmentsOptions = useMemo(
() =>
Object.entries(attachments).map(([name, item]) => ({
label: (
<Tooltip title={item.description} placement="right">
{item.title}
</Tooltip>
),
value: name,
})),
[attachments],
);
const actionsOptions = useMemo(
() =>
Object.entries(actions).map(([name, item]) => ({
label: (
<Tooltip title={item.description} placement="right">
{item.title}
</Tooltip>
),
value: name,
})),
[actions],
);
const t = useT();
return (
<SchemaSettingsModalItem
<SchemaComponent
components={{ InfoForm }}
scope={{ useAIEmployeeButtonVariableOptions }}
schema={{
type: 'object',
title: t('Edit'),
type: 'void',
properties: {
[uid()]: {
'x-component': 'FormV2',
'x-component-props': {
form,
},
properties: {
profile: {
type: 'void',
@ -118,12 +101,12 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
),
default: dn.getSchemaAttribute('x-component-props.taskDesc'),
},
manualMessage: {
type: 'boolean',
'x-content': t('Requires the user to enter a message manually.'),
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
default: dn.getSchemaAttribute('x-component-props.manualMessage') || false,
messageDivider: {
type: 'void',
'x-component': 'Divider',
'x-component-props': {
children: t('Default message'),
},
},
message: {
type: 'object',
@ -173,55 +156,84 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
},
},
},
attachments: {
type: 'array',
title: t('Attachments'),
'x-component': 'Checkbox.Group',
autoSend: {
type: 'boolean',
'x-content': t('Send default message automatically'),
'x-decorator': 'FormItem',
enum: attachmentsOptions,
default: dn.getSchemaAttribute('x-component-props.attachments'),
'x-reactions': {
target: 'attachments',
fulfill: {
state: {
visible: '{{$self.value.length}}',
'x-component': 'Checkbox',
default: dn.getSchemaAttribute('x-component-props.autoSend') || false,
},
formDivider: {
type: 'void',
'x-component': 'Divider',
'x-component-props': {
children: t('Required information form'),
},
},
infoForm: {
type: 'object',
'x-component': 'InfoForm',
'x-component-props': {
aiEmployee,
},
},
actions: {
type: 'array',
title: t('Actions'),
'x-component': 'Checkbox.Group',
'x-decorator': 'FormItem',
enum: actionsOptions,
default: dn.getSchemaAttribute('x-component-props.actions'),
'x-reactions': {
target: 'actions',
fulfill: {
state: {
visible: '{{$self.value.length}}',
},
default: dn.getSchemaAttribute('x-component-props.infoForm'),
},
},
},
},
}}
/>
);
});
export const aiEmployeeButtonSettings = new SchemaSettings({
name: 'aiEmployees:button',
items: [
{
name: 'edit',
Component: () => {
const t = useT();
const { dn } = useSchemaSettings();
const [open, setOpen] = useState(false);
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
const form = useMemo(() => createForm({}), []);
const { selectable } = useAISelectionContext();
return (
<div onClick={(e) => e.stopPropagation()}>
<SchemaSettingsItem title={t('Edit')} onClick={() => setOpen(true)} />
<Modal
styles={{
mask: {
zIndex: selectable ? -1 : 1000,
},
wrapper: {
zIndex: selectable ? -1 : 1000,
},
}}
title={t('Edit')}
onSubmit={({ message, taskDesc, manualMessage, attachments, actions }) => {
open={open}
onCancel={() => {
setOpen(false);
}}
onOk={() => {
const { taskDesc, message, autoSend, infoForm } = form.values;
dn.deepMerge({
'x-uid': dn.getSchemaAttribute('x-uid'),
'x-component-props': {
aiEmployee,
message,
taskDesc,
manualMessage,
attachments,
actions,
autoSend,
infoForm,
},
});
setOpen(false);
}}
/>
>
<SettingsForm form={form} aiEmployee={aiEmployee} />
</Modal>
</div>
);
},
},

View File

@ -9,12 +9,24 @@
import type { BubbleProps } from '@ant-design/x';
export type Selector = {
onSelect?: (ctx: any) => void;
};
export type AIEmployee = {
username: string;
nickname?: string;
avatar?: string;
bio?: string;
greeting?: string;
chatSettings?: {
senderPlaceholder?: string;
infoForm?: {
name: string;
title: string;
type: string;
}[];
};
};
export type Conversation = {
@ -41,10 +53,17 @@ export type Action = {
export type SendOptions = {
sessionId?: string;
greeting?: boolean;
aiEmployee?: AIEmployee;
messages: {
type: MessageType;
content: string;
}[];
infoFormValues?: any;
};
export type ShortcutOptions = {
aiEmployee: AIEmployee;
message: { type: MessageType; content: string };
infoFormValues: any;
autoSend: boolean;
};

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin, lazy } from '@nocobase/client';
import { CardItem, CollectionField, FormV2, Plugin, lazy } from '@nocobase/client';
import { AIManager } from './manager/ai-manager';
import { openaiProviderOptions } from './llm-providers/openai';
import { deepseekProviderOptions } from './llm-providers/deepseek';
@ -19,6 +19,7 @@ import { namespace } from './locale';
import { detailsAIEmployeesInitializer, formAIEmployeesInitializer } from './ai-employees/initializer/AIEmployees';
import { aiEmployeeButtonSettings } from './ai-employees/settings/AIEmployeeButton';
import { useDetailsAIEmployeeChatContext, useFormAIEmployeeChatContext } from './ai-employees/useBlockChatContext';
import { withAISelectable } from './ai-employees/selector/withAISelectable';
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
const { AIEmployeeChatProvider } = lazy(
() => import('./ai-employees/AIEmployeeChatProvider'),
@ -45,6 +46,12 @@ export class PluginAIClient extends Plugin {
this.app.addComponents({
AIEmployeeButton,
AIEmployeeChatProvider,
CardItem: withAISelectable(CardItem, {
selectType: 'blocks',
}),
CollectionField: withAISelectable(CollectionField, {
selectType: 'fields',
}),
});
this.app.addScopes({
useDetailsAIEmployeeChatContext,

View File

@ -40,6 +40,10 @@ export default {
type: 'string',
interface: 'textarea',
},
{
name: 'chatSettings',
type: 'jsonb',
},
{
name: 'skills',
type: 'jsonb',

View File

@ -21,20 +21,11 @@ export default defineCollection({
},
{
name: 'content',
type: 'text',
type: 'jsonb',
},
{
name: 'role',
type: 'string',
},
{
name: 'type',
type: 'string',
defaultValue: 'text',
},
{
name: 'title',
type: 'string',
},
],
});

View File

@ -10,8 +10,40 @@
import actions, { Context, Next } from '@nocobase/actions';
import snowflake from '../snowflake';
import PluginAIServer from '../plugin';
import { Model } from '@nocobase/database';
import { convertUiSchemaToJsonSchema } from '../utils';
import { Database, Model } from '@nocobase/database';
async function parseInfoMessage(db: Database, aiEmployee: Model, content: Record<string, any>) {
const infoForm: {
name: string;
title: string;
type: 'blocks' | 'collections';
}[] = aiEmployee.chatSettings?.infoForm;
if (!infoForm) {
return;
}
if (!content) {
return;
}
let info = '';
for (const key in content) {
const field = infoForm.find((item) => item.name === key);
if (field.type === 'blocks') {
const uiSchemaRepo = db.getRepository('uiSchemas') as any;
const schema = await uiSchemaRepo.getJsonSchema(content[key]);
if (!schema) {
return;
}
info += `${field.title}: ${JSON.stringify(schema)}; `;
} else {
info += `${field.title}: ${content[key]}; `;
}
}
if (!info) {
return;
}
return `The following information you can utilize in your conversation: ${info}`;
}
export default {
name: 'aiConversations',
@ -71,19 +103,10 @@ export default {
if (!conversation) {
ctx.throw(400);
}
const rows = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
ctx.body = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
sort: ['-messageId'],
limit: 10,
});
ctx.body = rows.map((row: Model) => ({
messageId: row.messageId,
role: row.role,
content: {
title: row.title,
content: row.content,
type: row.type,
},
}));
await next();
},
async sendMessages(ctx: Context, next: Next) {
@ -154,9 +177,7 @@ export default {
values: messages.map((message: any) => ({
messageId: snowflake.generate(),
role: message.role,
content: message.content.content,
type: message.content.type,
title: message.content.title,
content: message.content,
})),
});
} catch (err) {
@ -168,14 +189,12 @@ export default {
ctx.status = 200;
const userMessages = [];
for (const msg of messages) {
if (msg.role !== 'user') {
if (msg.role !== 'user' && msg.role !== 'info') {
continue;
}
let content = msg.content.content;
if (msg.content.type === 'uiSchema') {
const uiSchemaRepo = ctx.db.getRepository('uiSchemas');
const schema = await uiSchemaRepo.getJsonSchema(content);
content = JSON.stringify(convertUiSchemaToJsonSchema(schema));
if (msg.content.type === 'info') {
content = await parseInfoMessage(ctx.db, employee, content);
}
userMessages.push({
role: 'user',
@ -215,13 +234,20 @@ export default {
message += chunk.content;
ctx.res.write(`data: ${JSON.stringify({ type: 'content', body: chunk.content })}\n\n`);
}
if (!message) {
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'No content' })}\n\n`);
ctx.res.end();
return next();
}
await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: {
messageId: snowflake.generate(),
role: aiEmployee,
content: {
content: message,
type: 'text',
},
},
});
ctx.res.end();
await next();