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" "@nocobase/test": "1.x"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/x": "^1.0.5", "@ant-design/x": "^1.1.0",
"@langchain/core": "^0.3.39", "@langchain/core": "^0.3.39",
"@langchain/deepseek": "^0.0.1", "@langchain/deepseek": "^0.0.1",
"@langchain/openai": "^0.4.3", "@langchain/openai": "^0.4.3",

View File

@ -12,6 +12,7 @@ import { createContext } from 'react';
import { ChatBoxProvider } from './chatbox/ChatBoxProvider'; import { ChatBoxProvider } from './chatbox/ChatBoxProvider';
import { useAPIClient, useRequest } from '@nocobase/client'; import { useAPIClient, useRequest } from '@nocobase/client';
import { AIEmployee } from './types'; import { AIEmployee } from './types';
import { AISelectionProvider } from './selector/AISelectorProvider';
export const AIEmployeesContext = createContext<{ export const AIEmployeesContext = createContext<{
aiEmployees: AIEmployee[]; aiEmployees: AIEmployee[];
@ -24,9 +25,11 @@ export const AIEmployeesProvider: React.FC<{
const [aiEmployees, setAIEmployees] = React.useState<AIEmployee[]>(null); const [aiEmployees, setAIEmployees] = React.useState<AIEmployee[]>(null);
return ( return (
<AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}> <AISelectionProvider>
<ChatBoxProvider>{props.children}</ChatBoxProvider> <AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}>
</AIEmployeesContext.Provider> <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 React, { useContext, useState } from 'react';
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, App } from 'antd'; import { Layout, Card, Button } from 'antd';
import { Conversations, Sender, Bubble } from '@ant-design/x'; import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined } from '@ant-design/icons';
import type { ConversationsProps } from '@ant-design/x'; import { useToken } from '@nocobase/client';
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, DeleteOutlined } from '@ant-design/icons';
import { useAPIClient, useToken } from '@nocobase/client';
import { useT } from '../../locale';
const { Header, Footer, Sider, Content } = Layout; 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 { 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 = () => { export const ChatBox: React.FC = () => {
const { modal, message } = App.useApp(); const { setOpen, startNewConversation, currentEmployee } = useContext(ChatBoxContext);
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 { token } = useToken(); const { token } = useToken();
const [showConversations, setShowConversations] = useState(false); const [showConversations, setShowConversations] = useState(false);
const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])]; const { selectable } = useAISelectionContext();
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());
};
return ( return (
<div <div
@ -90,111 +34,11 @@ export const ChatBox: React.FC = () => {
maxWidth: '760px', maxWidth: '760px',
height: '90%', height: '90%',
maxHeight: '560px', 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%' }}> <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 <Sider
width="30%" width="30%"
style={{ style={{
@ -203,57 +47,7 @@ export const ChatBox: React.FC = () => {
marginRight: '5px', marginRight: '5px',
}} }}
> >
<Layout> <Conversations />
<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>
</Sider> </Sider>
<Layout> <Layout>
<Header <Header
@ -275,15 +69,7 @@ export const ChatBox: React.FC = () => {
type="text" type="text"
onClick={() => setShowConversations(!showConversations)} onClick={() => setShowConversations(!showConversations)}
/> />
{filterEmployee !== 'all' ? ( {currentEmployee ? <Button icon={<EditOutlined />} type="text" onClick={startNewConversation} /> : null}
<Button
icon={<EditOutlined />}
type="text"
onClick={() => {
clear();
}}
/>
) : null}
</div> </div>
<div <div
style={{ style={{
@ -301,27 +87,7 @@ export const ChatBox: React.FC = () => {
position: 'relative', position: 'relative',
}} }}
> >
{messages?.length ? ( <Messages />
<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>
)}
</Content> </Content>
<Footer <Footer
style={{ style={{
@ -329,61 +95,8 @@ export const ChatBox: React.FC = () => {
padding: 0, padding: 0,
}} }}
> >
<Sender <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}
/>
</Footer> </Footer>
{/* </Layout> */}
{/* <Sider */}
{/* width="25%" */}
{/* style={{ */}
{/* backgroundColor: token.colorBgContainer, */}
{/* }} */}
{/* > */}
{/* <Conversations items={employees} /> */}
{/* </Sider> */}
{/* </Layout> */}
</Layout> </Layout>
</Layout> </Layout>
</Card> </Card>

View File

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

View File

@ -13,13 +13,13 @@ import { CurrentUserContext } from '@nocobase/client';
import { ChatBox } from './ChatBox'; import { ChatBox } from './ChatBox';
import icon from '../icon.svg'; import icon from '../icon.svg';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ChatBoxContext, useChatBoxContext } from './ChatBoxContext'; import { ChatBoxContext, useSetChatBoxContext } from './ChatBoxContext';
export const ChatBoxProvider: React.FC<{ export const ChatBoxProvider: React.FC<{
children: React.ReactNode; children: React.ReactNode;
}> = (props) => { }> = (props) => {
const currentUserCtx = useContext(CurrentUserContext); const currentUserCtx = useContext(CurrentUserContext);
const chatBoxCtx = useChatBoxContext(); const chatBoxCtx = useSetChatBoxContext();
const { open, setOpen } = chatBoxCtx; const { open, setOpen } = chatBoxCtx;
if (!currentUserCtx?.data?.data) { 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 api = useAPIClient();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [responseLoading, setResponseLoading] = useState(false); const [responseLoading, setResponseLoading] = useState(false);
const [attachments, setAttachments] = useState<AttachmentProps[]>([]);
const [actions, setActions] = useState<Action[]>([]);
const clearMessages = () => {
setMessages([]);
setAttachments([]);
setActions([]);
};
const addMessage = (message: Message) => { const addMessage = (message: Message) => {
setMessages((prev) => [...prev, message]); setMessages((prev) => [...prev, message]);
@ -108,34 +100,24 @@ export const useChatMessages = () => {
sessionId, sessionId,
aiEmployee, aiEmployee,
messages: sendMsgs, messages: sendMsgs,
greeting, infoFormValues,
onConversationCreate, onConversationCreate,
}: SendOptions & { }: SendOptions & {
onConversationCreate?: (sessionId: string) => void; onConversationCreate?: (sessionId: string) => void;
}) => { }) => {
const msgs: Message[] = []; 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) { if (!sendMsgs.length) {
addMessages(msgs);
return; return;
} }
if (attachments.length) { if (infoFormValues) {
msgs.push( msgs.push({
...attachments.map((attachment) => ({ key: uid(),
key: uid(), role: 'info',
role: 'user', content: {
content: attachment, type: 'info',
})), content: infoFormValues,
); },
});
} }
msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg }))); msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg })));
addMessages(msgs); addMessages(msgs);
@ -152,7 +134,6 @@ export const useChatMessages = () => {
sessionId = conversation.sessionId; sessionId = conversation.sessionId;
onConversationCreate?.(sessionId); onConversationCreate?.(sessionId);
} }
setAttachments([]);
setResponseLoading(true); setResponseLoading(true);
addMessage({ addMessage({
@ -183,34 +164,31 @@ export const useChatMessages = () => {
return; return;
} }
const { result, error } = await processStreamResponse(sendRes.data); const { result, error } = await processStreamResponse(sendRes.data);
if (actions.length && !error) { // if (actions.length && !error) {
addMessages( // addMessages(
actions.map((action) => ({ // actions.map((action) => ({
key: uid(), // key: uid(),
role: 'action', // role: 'action',
content: { // content: {
type: 'action', // type: 'action',
icon: action.icon, // icon: action.icon,
content: action.content, // content: action.content,
onClick: () => { // onClick: () => {
action.onClick(result); // action.onClick(result);
}, // },
}, // },
})), // })),
); // );
setActions([]); // setActions([]);
} // }
}; };
return { return {
messages, messages,
addMessage,
addMessages,
setMessages, setMessages,
attachments,
setAttachments,
actions,
setActions,
responseLoading, responseLoading,
sendMessages, sendMessages,
clearMessages,
}; };
}; };

View File

@ -7,24 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { useContext } from 'react'; import React from 'react';
import { Avatar, Tag, Popover, Divider, Button } from 'antd'; import { Avatar, Popover, Button } from 'antd';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { import {
SortableItem, SortableItem,
useBlockContext,
useLocalVariables, useLocalVariables,
useSchemaToolbarRender, useSchemaToolbarRender,
useToken,
useVariables, useVariables,
withDynamicSchemaProps, withDynamicSchemaProps,
} from '@nocobase/client'; } from '@nocobase/client';
import { useFieldSchema } from '@formily/react'; import { useFieldSchema } from '@formily/react';
import { useT } from '../../locale'; import { useChatBoxContext } from '../chatbox/ChatBoxContext';
import { css } from '@emotion/css';
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
import { ChatBoxContext } from '../chatbox/ChatBoxContext';
import { AIEmployee } from '../types'; import { AIEmployee } from '../types';
import { ProfileCard } from '../ProfileCard';
async function replaceVariables(template, variables, localVariables = {}) { async function replaceVariables(template, variables, localVariables = {}) {
const regex = /\{\{\s*(.*?)\s*\}\}/g; const regex = /\{\{\s*(.*?)\s*\}\}/g;
@ -64,17 +60,18 @@ async function replaceVariables(template, variables, localVariables = {}) {
export const AIEmployeeButton: React.FC<{ export const AIEmployeeButton: React.FC<{
aiEmployee: AIEmployee; aiEmployee: AIEmployee;
taskDesc?: string; taskDesc?: string;
attachments: string[]; autoSend?: boolean;
actions: string[]; message: {
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, attachments: selectedAttachments, actions: selectedActions }) => { type: string;
const t = useT(); content: string;
const { setOpen, send, setAttachments, setFilterEmployee, setActions, clear } = useContext(ChatBoxContext); };
const { token } = useToken(); infoForm: any;
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, message, infoForm: infoFormValues, autoSend }) => {
const { triggerShortcut } = useChatBoxContext();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const { render } = useSchemaToolbarRender(fieldSchema); const { render } = useSchemaToolbarRender(fieldSchema);
const variables = useVariables(); const variables = useVariables();
const localVariables = useLocalVariables(); const localVariables = useLocalVariables();
const { attachments, actions } = useAIEmployeeChatContext();
return ( return (
<SortableItem <SortableItem
@ -82,110 +79,23 @@ export const AIEmployeeButton: React.FC<{
position: 'relative', position: 'relative',
}} }}
onClick={async () => { onClick={async () => {
clear(); let msg;
setOpen(true); if (message && message.content) {
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) {
const content = await replaceVariables(message.content, variables, localVariables); const content = await replaceVariables(message.content, variables, localVariables);
messages.push({ msg = {
type: message.type || 'text', type: message.type || 'text',
content, content,
}); };
} }
send({ triggerShortcut({
aiEmployee, aiEmployee,
messages, message: msg,
greeting: true, autoSend,
infoFormValues,
}); });
}} }}
> >
<Popover <Popover content={<ProfileCard taskDesc={taskDesc} aiEmployee={aiEmployee} />}>
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>
}
>
<Button <Button
shape="circle" shape="circle"
style={{ 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; const { Meta } = Card;
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { AvatarSelect } from './AvatarSelect';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { createForm } from '@formily/core'; import { createForm } from '@formily/core';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { ModelSettings } from './ModelSettings'; import { ModelSettings } from './ModelSettings';
import { ProfileSettings } from './ProfileSettings';
import { ChatSettings } from './ChatSettings';
const EmployeeContext = createContext(null); const EmployeeContext = createContext(null);
@ -38,63 +39,12 @@ const AIEmployeeForm: React.FC = () => {
{ {
key: 'profile', key: 'profile',
label: 'Profile', label: 'Profile',
children: ( children: <ProfileSettings />,
<SchemaComponent },
components={{ AvatarSelect }} {
schema={{ key: 'chat',
type: 'void', label: 'Chat settings',
properties: { children: <ChatSettings />,
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',
},
},
}}
/>
),
}, },
// { // {
// key: 'skills', // key: 'skills',
@ -178,41 +128,7 @@ const AIEmployeeForm: React.FC = () => {
{ {
key: 'modelSettings', key: 'modelSettings',
label: 'Model Settings', label: 'Model Settings',
children: ( children: <ModelSettings />,
<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',
},
},
}}
/>
),
}, },
]} ]}
/> />

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 { 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 React from 'react';
import PluginAIClient from '../../'; import PluginAIClient from '../../';
export const useModelSettingsForm = (provider: string) => { const useModelOptionsForm = (provider: string) => {
const plugin = usePlugin(PluginAIClient); const plugin = usePlugin(PluginAIClient);
const p = plugin.aiManager.llmProviders.get(provider); const p = plugin.aiManager.llmProviders.get(provider);
return p?.components?.ModelSettingsForm; return p?.components?.ModelSettingsForm;
}; };
export const ModelSettings = observer( const ModelOptions = observer(
() => { () => {
const form = useForm(); const form = useForm();
const api = useAPIClient(); const api = useAPIClient();
@ -33,11 +33,49 @@ export const ModelSettings = observer(
refreshDeps: [form.values?.modelSettings?.llmService], refreshDeps: [form.values?.modelSettings?.llmService],
}, },
); );
const Component = useModelSettingsForm(data?.provider); const Component = useModelOptionsForm(data?.provider);
if (loading) { if (loading) {
return null; return null;
} }
return Component ? <Component /> : 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. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { useMemo } from 'react'; import React, { memo, useMemo, useState } from 'react';
import { import {
SchemaComponent,
SchemaSettings, SchemaSettings,
SchemaSettingsModalItem, SchemaSettingsItem,
useBlockContext, useBlockContext,
useCollection, useCollection,
useCollectionFilterOptions, useCollectionFilterOptions,
@ -19,10 +20,14 @@ import {
} from '@nocobase/client'; } from '@nocobase/client';
import { useT } from '../../locale'; import { useT } from '../../locale';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { Card, Avatar, Tooltip } from 'antd'; import { Card, Avatar, Tooltip, Modal } from 'antd';
const { Meta } = Card; const { Meta } = Card;
import { Schema } from '@formily/react'; 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 = () => { export const useAIEmployeeButtonVariableOptions = () => {
const collection = useCollection(); const collection = useCollection();
@ -49,6 +54,138 @@ export const useAIEmployeeButtonVariableOptions = () => {
}, [recordData, t, fields, blockType]); }, [recordData, t, fields, blockType]);
}; };
const SettingsForm: React.FC<{
form: any;
aiEmployee: AIEmployee;
}> = memo(({ form, aiEmployee }) => {
const { dn } = useSchemaSettings();
const t = useT();
return (
<SchemaComponent
components={{ InfoForm }}
scope={{ useAIEmployeeButtonVariableOptions }}
schema={{
type: 'void',
properties: {
[uid()]: {
'x-component': 'FormV2',
'x-component-props': {
form,
},
properties: {
profile: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': () => (
<Card
variant="borderless"
style={{
maxWidth: 520,
}}
>
<Meta
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} size={48} /> : null}
title={aiEmployee.nickname}
description={aiEmployee.bio}
/>
</Card>
),
},
taskDesc: {
type: 'string',
title: t('Task description'),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
description: t(
'Displays the AI employees assigned tasks on the profile when hovering over the button.',
),
default: dn.getSchemaAttribute('x-component-props.taskDesc'),
},
messageDivider: {
type: 'void',
'x-component': 'Divider',
'x-component-props': {
children: t('Default message'),
},
},
message: {
type: 'object',
properties: {
messageType: {
type: 'string',
title: t('Message type'),
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{
label: t('Text'),
value: 'text',
},
{
label: t('Image'),
value: 'image',
},
],
default: 'text',
'x-component-props': {
placeholder: t('Message type'),
},
},
content: {
title: t('Message content'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: '{{ useAIEmployeeButtonVariableOptions }}',
changeOnSelect: true,
fieldNames: {
value: 'name',
label: 'title',
},
},
},
},
default: dn.getSchemaAttribute('x-component-props.message'),
'x-reactions': {
dependencies: ['.manualMessage'],
fulfill: {
state: {
visible: '{{ !$deps[0] }}',
},
},
},
},
autoSend: {
type: 'boolean',
'x-content': t('Send default message automatically'),
'x-decorator': 'FormItem',
'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,
},
default: dn.getSchemaAttribute('x-component-props.infoForm'),
},
},
},
},
}}
/>
);
});
export const aiEmployeeButtonSettings = new SchemaSettings({ export const aiEmployeeButtonSettings = new SchemaSettings({
name: 'aiEmployees:button', name: 'aiEmployees:button',
items: [ items: [
@ -57,171 +194,46 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
Component: () => { Component: () => {
const t = useT(); const t = useT();
const { dn } = useSchemaSettings(); const { dn } = useSchemaSettings();
const [open, setOpen] = useState(false);
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {}; const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
const { attachments = [], actions = [] } = useAIEmployeeChatContext(); const form = useMemo(() => createForm({}), []);
const attachmentsOptions = useMemo( const { selectable } = useAISelectionContext();
() =>
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],
);
return ( return (
<SchemaSettingsModalItem <div onClick={(e) => e.stopPropagation()}>
scope={{ useAIEmployeeButtonVariableOptions }} <SchemaSettingsItem title={t('Edit')} onClick={() => setOpen(true)} />
schema={{ <Modal
type: 'object', styles={{
title: t('Edit'), mask: {
properties: { zIndex: selectable ? -1 : 1000,
profile: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': () => (
<Card
variant="borderless"
style={{
maxWidth: 520,
}}
>
<Meta
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} size={48} /> : null}
title={aiEmployee.nickname}
description={aiEmployee.bio}
/>
</Card>
),
}, },
taskDesc: { wrapper: {
type: 'string', zIndex: selectable ? -1 : 1000,
title: t('Task description'),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
description: t(
'Displays the AI employees assigned tasks on the profile when hovering over the button.',
),
default: dn.getSchemaAttribute('x-component-props.taskDesc'),
}, },
manualMessage: { }}
type: 'boolean', title={t('Edit')}
'x-content': t('Requires the user to enter a message manually.'), open={open}
'x-decorator': 'FormItem', onCancel={() => {
'x-component': 'Checkbox', setOpen(false);
default: dn.getSchemaAttribute('x-component-props.manualMessage') || false, }}
}, onOk={() => {
message: { const { taskDesc, message, autoSend, infoForm } = form.values;
type: 'object', dn.deepMerge({
properties: { 'x-uid': dn.getSchemaAttribute('x-uid'),
messageType: { 'x-component-props': {
type: 'string', aiEmployee,
title: t('Message type'), message,
'x-decorator': 'FormItem', taskDesc,
'x-component': 'Select', autoSend,
enum: [ infoForm,
{
label: t('Text'),
value: 'text',
},
{
label: t('Image'),
value: 'image',
},
],
default: 'text',
'x-component-props': {
placeholder: t('Message type'),
},
},
content: {
title: t('Message content'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: '{{ useAIEmployeeButtonVariableOptions }}',
changeOnSelect: true,
fieldNames: {
value: 'name',
label: 'title',
},
},
},
}, },
default: dn.getSchemaAttribute('x-component-props.message'), });
'x-reactions': { setOpen(false);
dependencies: ['.manualMessage'], }}
fulfill: { >
state: { <SettingsForm form={form} aiEmployee={aiEmployee} />
visible: '{{ !$deps[0] }}', </Modal>
}, </div>
},
},
},
attachments: {
type: 'array',
title: t('Attachments'),
'x-component': 'Checkbox.Group',
'x-decorator': 'FormItem',
enum: attachmentsOptions,
default: dn.getSchemaAttribute('x-component-props.attachments'),
'x-reactions': {
target: 'attachments',
fulfill: {
state: {
visible: '{{$self.value.length}}',
},
},
},
},
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}}',
},
},
},
},
},
}}
title={t('Edit')}
onSubmit={({ message, taskDesc, manualMessage, attachments, actions }) => {
dn.deepMerge({
'x-uid': dn.getSchemaAttribute('x-uid'),
'x-component-props': {
aiEmployee,
message,
taskDesc,
manualMessage,
attachments,
actions,
},
});
}}
/>
); );
}, },
}, },

View File

@ -9,12 +9,24 @@
import type { BubbleProps } from '@ant-design/x'; import type { BubbleProps } from '@ant-design/x';
export type Selector = {
onSelect?: (ctx: any) => void;
};
export type AIEmployee = { export type AIEmployee = {
username: string; username: string;
nickname?: string; nickname?: string;
avatar?: string; avatar?: string;
bio?: string; bio?: string;
greeting?: string; greeting?: string;
chatSettings?: {
senderPlaceholder?: string;
infoForm?: {
name: string;
title: string;
type: string;
}[];
};
}; };
export type Conversation = { export type Conversation = {
@ -41,10 +53,17 @@ export type Action = {
export type SendOptions = { export type SendOptions = {
sessionId?: string; sessionId?: string;
greeting?: boolean;
aiEmployee?: AIEmployee; aiEmployee?: AIEmployee;
messages: { messages: {
type: MessageType; type: MessageType;
content: string; 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. * 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 { AIManager } from './manager/ai-manager';
import { openaiProviderOptions } from './llm-providers/openai'; import { openaiProviderOptions } from './llm-providers/openai';
import { deepseekProviderOptions } from './llm-providers/deepseek'; import { deepseekProviderOptions } from './llm-providers/deepseek';
@ -19,6 +19,7 @@ import { namespace } from './locale';
import { detailsAIEmployeesInitializer, formAIEmployeesInitializer } from './ai-employees/initializer/AIEmployees'; import { detailsAIEmployeesInitializer, formAIEmployeesInitializer } from './ai-employees/initializer/AIEmployees';
import { aiEmployeeButtonSettings } from './ai-employees/settings/AIEmployeeButton'; import { aiEmployeeButtonSettings } from './ai-employees/settings/AIEmployeeButton';
import { useDetailsAIEmployeeChatContext, useFormAIEmployeeChatContext } from './ai-employees/useBlockChatContext'; import { useDetailsAIEmployeeChatContext, useFormAIEmployeeChatContext } from './ai-employees/useBlockChatContext';
import { withAISelectable } from './ai-employees/selector/withAISelectable';
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider'); const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
const { AIEmployeeChatProvider } = lazy( const { AIEmployeeChatProvider } = lazy(
() => import('./ai-employees/AIEmployeeChatProvider'), () => import('./ai-employees/AIEmployeeChatProvider'),
@ -45,6 +46,12 @@ export class PluginAIClient extends Plugin {
this.app.addComponents({ this.app.addComponents({
AIEmployeeButton, AIEmployeeButton,
AIEmployeeChatProvider, AIEmployeeChatProvider,
CardItem: withAISelectable(CardItem, {
selectType: 'blocks',
}),
CollectionField: withAISelectable(CollectionField, {
selectType: 'fields',
}),
}); });
this.app.addScopes({ this.app.addScopes({
useDetailsAIEmployeeChatContext, useDetailsAIEmployeeChatContext,

View File

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

View File

@ -21,20 +21,11 @@ export default defineCollection({
}, },
{ {
name: 'content', name: 'content',
type: 'text', type: 'jsonb',
}, },
{ {
name: 'role', name: 'role',
type: 'string', 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 actions, { Context, Next } from '@nocobase/actions';
import snowflake from '../snowflake'; import snowflake from '../snowflake';
import PluginAIServer from '../plugin'; import PluginAIServer from '../plugin';
import { Model } from '@nocobase/database';
import { convertUiSchemaToJsonSchema } from '../utils'; 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 { export default {
name: 'aiConversations', name: 'aiConversations',
@ -71,19 +103,10 @@ export default {
if (!conversation) { if (!conversation) {
ctx.throw(400); 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'], sort: ['-messageId'],
limit: 10, 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(); await next();
}, },
async sendMessages(ctx: Context, next: Next) { async sendMessages(ctx: Context, next: Next) {
@ -154,9 +177,7 @@ export default {
values: messages.map((message: any) => ({ values: messages.map((message: any) => ({
messageId: snowflake.generate(), messageId: snowflake.generate(),
role: message.role, role: message.role,
content: message.content.content, content: message.content,
type: message.content.type,
title: message.content.title,
})), })),
}); });
} catch (err) { } catch (err) {
@ -168,14 +189,12 @@ export default {
ctx.status = 200; ctx.status = 200;
const userMessages = []; const userMessages = [];
for (const msg of messages) { for (const msg of messages) {
if (msg.role !== 'user') { if (msg.role !== 'user' && msg.role !== 'info') {
continue; continue;
} }
let content = msg.content.content; let content = msg.content.content;
if (msg.content.type === 'uiSchema') { if (msg.content.type === 'info') {
const uiSchemaRepo = ctx.db.getRepository('uiSchemas'); content = await parseInfoMessage(ctx.db, employee, content);
const schema = await uiSchemaRepo.getJsonSchema(content);
content = JSON.stringify(convertUiSchemaToJsonSchema(schema));
} }
userMessages.push({ userMessages.push({
role: 'user', role: 'user',
@ -215,12 +234,19 @@ export default {
message += chunk.content; message += chunk.content;
ctx.res.write(`data: ${JSON.stringify({ type: 'content', body: chunk.content })}\n\n`); 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({ await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: { values: {
messageId: snowflake.generate(), messageId: snowflake.generate(),
role: aiEmployee, role: aiEmployee,
content: message, content: {
type: 'text', content: message,
type: 'text',
},
}, },
}); });
ctx.res.end(); ctx.res.end();