chore: update

This commit is contained in:
xilesun 2025-04-22 22:40:48 +08:00
parent 861f0e5f02
commit c4f5ff9a29
20 changed files with 719 additions and 296 deletions

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 React, { useContext } from 'react'; import React, { useContext, useMemo } from 'react';
import { createContext } from 'react'; 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';
@ -46,12 +46,18 @@ export const useAIEmployeesContext = () => {
() => () =>
api api
.resource('aiEmployees') .resource('aiEmployees')
.list() .listByUser()
.then((res) => res?.data?.data), .then((res) => res?.data?.data),
{ {
ready: !aiEmployees, ready: !aiEmployees,
onSuccess: (aiEmployees) => setAIEmployees(aiEmployees), onSuccess: (aiEmployees) => setAIEmployees(aiEmployees),
}, },
); );
return { aiEmployees, service }; const aiEmployeesMap = useMemo(() => {
return (aiEmployees || []).reduce((acc, aiEmployee) => {
acc[aiEmployee.username] = aiEmployee;
return acc;
}, {});
}, [aiEmployees]);
return { aiEmployees, aiEmployeesMap, service };
}; };

View File

@ -7,9 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { useCallback } from 'react'; import React from 'react';
import { List, Popover, Button, Avatar, Divider } from 'antd'; import { List, Popover, Button, Avatar } from 'antd';
import { useToken } from '@nocobase/client';
import { useAIEmployeesContext } from '../AIEmployeesProvider'; import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { useT } from '../../locale'; import { useT } from '../../locale';
import { useChatBoxContext } from './ChatBoxContext'; import { useChatBoxContext } from './ChatBoxContext';
@ -17,10 +16,6 @@ import { avatars } from '../avatars';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Sender } from '@ant-design/x'; import { Sender } from '@ant-design/x';
import { ProfileCard } from '../ProfileCard'; import { ProfileCard } from '../ProfileCard';
import { AIEmployee } from '../types';
import { uid } from '@formily/shared';
import { useChatMessages } from './ChatMessagesProvider';
import { useChatConversations } from './ChatConversationsProvider';
export const AIEmployeeHeader: React.FC = () => { export const AIEmployeeHeader: React.FC = () => {
const { const {
@ -28,36 +23,7 @@ export const AIEmployeeHeader: React.FC = () => {
aiEmployees, aiEmployees,
} = useAIEmployeesContext(); } = useAIEmployeesContext();
const t = useT(); const t = useT();
const { setMessages, addMessage } = useChatMessages(); const switchAIEmployee = useChatBoxContext('switchAIEmployee');
const { currentConversation } = useChatConversations();
const setCurrentEmployee = useChatBoxContext('setCurrentEmployee');
const setSenderPlaceholder = useChatBoxContext('setSenderPlaceholder');
const setSenderValue = useChatBoxContext('setSenderValue');
const senderRef = useChatBoxContext('senderRef');
const infoForm = useChatBoxContext('infoForm');
const switchAIEmployee = useCallback(
(aiEmployee: AIEmployee) => {
const greetingMsg = {
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting' as const,
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('');
}
},
[currentConversation, infoForm],
);
return ( return (
<Sender.Header closable={false}> <Sender.Header closable={false}>

View File

@ -42,6 +42,7 @@ type ChatBoxContextValues = {
setSenderPlaceholder: React.Dispatch<React.SetStateAction<string>>; setSenderPlaceholder: React.Dispatch<React.SetStateAction<string>>;
startNewConversation: () => void; startNewConversation: () => void;
triggerShortcut: (options: ShortcutOptions) => void; triggerShortcut: (options: ShortcutOptions) => void;
switchAIEmployee: (aiEmployee: AIEmployee) => void;
send(opts: SendOptions): void; send(opts: SendOptions): void;
}; };
@ -113,7 +114,7 @@ export const useSetChatBoxContext = () => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showConversations, setShowConversations] = useState(false); const [showConversations, setShowConversations] = useState(false);
const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null); const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null);
const { setMessages, sendMessages } = useChatMessages(); const { setMessages, sendMessages, addMessage } = useChatMessages();
const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations(); const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations();
const [senderValue, setSenderValue] = useState<string>(''); const [senderValue, setSenderValue] = useState<string>('');
const [senderPlaceholder, setSenderPlaceholder] = useState<string>(''); const [senderPlaceholder, setSenderPlaceholder] = useState<string>('');
@ -156,6 +157,33 @@ export const useSetChatBoxContext = () => {
senderRef.current?.focus(); senderRef.current?.focus();
}, [currentEmployee]); }, [currentEmployee]);
const switchAIEmployee = useCallback(
(aiEmployee: AIEmployee) => {
setCurrentEmployee(aiEmployee);
setSenderValue('');
if (aiEmployee) {
const greetingMsg = {
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting' as const,
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
};
setSenderPlaceholder(aiEmployee.chatSettings?.senderPlaceholder);
senderRef.current?.focus();
if (!currentConversation) {
setMessages([greetingMsg]);
} else {
addMessage(greetingMsg);
}
} else {
setMessages([]);
}
},
[currentConversation],
);
const triggerShortcut = useCallback( const triggerShortcut = useCallback(
(options: ShortcutOptions) => { (options: ShortcutOptions) => {
const { aiEmployee, message, autoSend } = options; const { aiEmployee, message, autoSend } = options;
@ -238,6 +266,7 @@ export const useSetChatBoxContext = () => {
setSenderPlaceholder, setSenderPlaceholder,
startNewConversation, startNewConversation,
triggerShortcut, triggerShortcut,
switchAIEmployee,
send, send,
}; };
}; };

View File

@ -7,51 +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, { useCallback, useMemo } from 'react'; import React, { useMemo } from 'react';
import { FloatButton, Avatar, Dropdown } from 'antd'; import { FloatButton, Avatar, Dropdown } from 'antd';
import icon from '../icon.svg'; import icon from '../icon.svg';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useChatBoxContext } from './ChatBoxContext'; import { useChatBoxContext } from './ChatBoxContext';
import { useAIEmployeesContext } from '../AIEmployeesProvider'; import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { useT } from '../../locale';
import { useChatMessages } from './ChatMessagesProvider';
import { useChatConversations } from './ChatConversationsProvider';
import { AIEmployee } from '../types';
import { uid } from '@formily/shared';
export const ChatButton: React.FC = () => { export const ChatButton: React.FC = () => {
const t = useT();
const { aiEmployees } = useAIEmployeesContext(); const { aiEmployees } = useAIEmployeesContext();
const open = useChatBoxContext('open');
const setOpen = useChatBoxContext('setOpen'); const setOpen = useChatBoxContext('setOpen');
const setCurrentEmployee = useChatBoxContext('setCurrentEmployee'); const switchAIEmployee = useChatBoxContext('switchAIEmployee');
const { setMessages, addMessage } = useChatMessages();
const { currentConversation } = useChatConversations();
const setSenderPlaceholder = useChatBoxContext('setSenderPlaceholder');
const setSenderValue = useChatBoxContext('setSenderValue');
const senderRef = useChatBoxContext('senderRef');
const switchAIEmployee = useCallback(
(aiEmployee: AIEmployee) => {
const greetingMsg = {
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting' as const,
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
};
setCurrentEmployee(aiEmployee);
setSenderPlaceholder(aiEmployee.chatSettings?.senderPlaceholder);
senderRef.current?.focus();
if (!currentConversation) {
setMessages([greetingMsg]);
} else {
addMessage(greetingMsg);
setSenderValue('');
}
},
[currentConversation],
);
const items = useMemo(() => { const items = useMemo(() => {
return aiEmployees?.map((employee) => ({ return aiEmployees?.map((employee) => ({
key: employee.username, key: employee.username,
@ -89,7 +58,7 @@ export const ChatButton: React.FC = () => {
padding: 0; padding: 0;
} }
.ant-float-btn .ant-float-btn-body .ant-float-btn-content .ant-float-btn-icon { .ant-float-btn .ant-float-btn-body .ant-float-btn-content .ant-float-btn-icon {
width: 40px; width: 36px;
} }
`} `}
> >
@ -98,15 +67,15 @@ export const ChatButton: React.FC = () => {
icon={ icon={
<Avatar <Avatar
src={icon} src={icon}
size={40} size={36}
style={{ style={{
marginBottom: '4px', marginBottom: '4px',
}} }}
/> />
} }
// onClick={() => { onClick={() => {
// setOpen(false); setOpen(!open);
// }} }}
shape="square" shape="square"
/> />
</Dropdown> </Dropdown>

View File

@ -16,6 +16,7 @@ import { SenderHeader } from './SenderHeader';
import { SenderFooter } from './SenderFooter'; import { SenderFooter } from './SenderFooter';
import { useChatConversations } from './ChatConversationsProvider'; import { useChatConversations } from './ChatConversationsProvider';
import { useChatMessages } from './ChatMessagesProvider'; import { useChatMessages } from './ChatMessagesProvider';
import { AIEmployeeHeader } from './AIEmployeeHeader';
export const Sender: React.FC = () => { export const Sender: React.FC = () => {
const t = useT(); const t = useT();
@ -60,7 +61,7 @@ export const Sender: React.FC = () => {
} }
onCancel={cancelRequest} onCancel={cancelRequest}
prefix={<SenderPrefix />} prefix={<SenderPrefix />}
// header={!currentConversation ? <SenderHeader /> : null} header={!currentEmployee ? <AIEmployeeHeader /> : null}
loading={responseLoading} loading={responseLoading}
footer={({ components }) => <SenderFooter components={components} />} footer={({ components }) => <SenderFooter components={components} />}
disabled={!currentEmployee} disabled={!currentEmployee}

View File

@ -14,5 +14,14 @@ import { Avatar } from 'antd';
export const SenderPrefix: React.FC = () => { export const SenderPrefix: React.FC = () => {
const currentEmployee = useChatBoxContext('currentEmployee'); const currentEmployee = useChatBoxContext('currentEmployee');
return currentEmployee ? <Avatar src={avatars(currentEmployee.avatar)} /> : null; const switchAIEmployee = useChatBoxContext('switchAIEmployee');
return currentEmployee ? (
<Avatar
src={avatars(currentEmployee.avatar)}
style={{
cursor: 'pointer',
}}
onClick={() => switchAIEmployee(null)}
/>
) : null;
}; };

View File

@ -8,7 +8,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Avatar, Popover, Button } from 'antd'; import { Avatar, Popover, Button, Spin } from 'antd';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { import {
SortableItem, SortableItem,
@ -21,6 +21,7 @@ import { useFieldSchema } from '@formily/react';
import { useChatBoxContext } from '../chatbox/ChatBoxContext'; import { useChatBoxContext } from '../chatbox/ChatBoxContext';
import { AIEmployee } from '../types'; import { AIEmployee } from '../types';
import { ProfileCard } from '../ProfileCard'; import { ProfileCard } from '../ProfileCard';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
async function replaceVariables(template, variables, localVariables = {}) { async function replaceVariables(template, variables, localVariables = {}) {
const regex = /\{\{\s*(.*?)\s*\}\}/g; const regex = /\{\{\s*(.*?)\s*\}\}/g;
@ -58,20 +59,24 @@ async function replaceVariables(template, variables, localVariables = {}) {
} }
export const AIEmployeeButton: React.FC<{ export const AIEmployeeButton: React.FC<{
aiEmployee: AIEmployee; username: string;
taskDesc?: string; taskDesc?: string;
autoSend?: boolean; autoSend?: boolean;
message: { message: {
type: string; type: string;
content: string; content: string;
}; };
infoForm: any; }> = withDynamicSchemaProps(({ username, taskDesc, message, autoSend }) => {
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, message, infoForm: infoFormValues, autoSend }) => {
const triggerShortcut = useChatBoxContext('triggerShortcut'); const triggerShortcut = useChatBoxContext('triggerShortcut');
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 {
aiEmployeesMap,
service: { loading },
} = useAIEmployeesContext();
const aiEmployee = aiEmployeesMap[username];
return ( return (
<SortableItem <SortableItem
@ -92,10 +97,10 @@ export const AIEmployeeButton: React.FC<{
aiEmployee, aiEmployee,
message: msg, message: msg,
autoSend, autoSend,
infoFormValues,
}); });
}} }}
> >
<Spin spinning={loading}>
<Popover content={<ProfileCard taskDesc={taskDesc} aiEmployee={aiEmployee} />}> <Popover content={<ProfileCard taskDesc={taskDesc} aiEmployee={aiEmployee} />}>
<Button <Button
shape="circle" shape="circle"
@ -104,10 +109,11 @@ export const AIEmployeeButton: React.FC<{
height: '36px', height: '36px',
}} }}
> >
<Avatar src={avatars(aiEmployee.avatar)} size={36} /> {aiEmployee && <Avatar src={avatars(aiEmployee.avatar)} size={36} />}
</Button> </Button>
</Popover> </Popover>
{render()} {render()}
</Spin>
</SortableItem> </SortableItem>
); );
}); });

View File

@ -41,7 +41,7 @@ const getAIEmployeesInitializer = () => ({
'x-toolbar': 'ActionSchemaToolbar', 'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'aiEmployees:button', 'x-settings': 'aiEmployees:button',
'x-component-props': { 'x-component-props': {
aiEmployee, username: aiEmployee.username,
}, },
}); });
}; };

View File

@ -10,11 +10,11 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import cls from 'classnames'; import cls from 'classnames';
import { useToken, useUploadStyles } from '@nocobase/client'; import { useToken, useUploadStyles } from '@nocobase/client';
import useUploadStyle from 'antd/es/upload/style';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useField } from '@formily/react'; import { useField } from '@formily/react';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { List, Avatar as AntdAvatar } from 'antd';
export const Avatar: React.FC<{ export const Avatar: React.FC<{
srcs: [string, string][]; srcs: [string, string][];
@ -22,11 +22,9 @@ export const Avatar: React.FC<{
selectable?: boolean; selectable?: boolean;
highlightItem?: string; highlightItem?: string;
onClick?: (name: string) => void; onClick?: (name: string) => void;
}> = ({ srcs, size, selectable, highlightItem, onClick }) => { }> = ({ srcs, size = 'large', selectable, highlightItem, onClick }) => {
const { token } = useToken(); const { token } = useToken();
const { wrapSSR, hashId, componentCls: prefixCls } = useUploadStyles(); const { wrapSSR, hashId, componentCls: prefixCls } = useUploadStyles();
const useUploadStyleVal = (useUploadStyle as any).default ? (useUploadStyle as any).default : useUploadStyle;
useUploadStyleVal(prefixCls);
const list = const list =
srcs?.map(([src, name], index) => ( srcs?.map(([src, name], index) => (
@ -61,6 +59,44 @@ export const Avatar: React.FC<{
</div> </div>
)) || []; )) || [];
return (
<List
itemLayout="horizontal"
dataSource={srcs}
renderItem={([src, name]) => {
return (
<AntdAvatar
size={size === 'small' ? 32 : 80}
className={cls(
css`
margin: 2px;
border-radius: ${size === 'small' ? '4px' : '8px'};
border: 1px solid ${token.colorBorder};
padding: 1px;
`,
highlightItem === name
? css`
border-color: ${token.colorPrimary} !important;
`
: '',
selectable
? css`
cursor: pointer;
&:hover {
border-color: ${token.colorPrimary} !important;
}
`
: '',
)}
src={src}
shape="square"
onClick={() => onClick && onClick(name)}
/>
);
}}
/>
);
return wrapSSR( return wrapSSR(
<div <div
className={cls( className={cls(

View File

@ -8,13 +8,31 @@
*/ */
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { Card, Row, Col, Avatar, Input, Space, Button, Tabs, App, Spin, Empty, Typography, Tag } from 'antd'; import {
Card,
Row,
Col,
Avatar as AntdAvatar,
Input,
Space,
Button,
Tabs,
App,
Spin,
Empty,
Typography,
Tag,
} from 'antd';
import { import {
CollectionRecordProvider, CollectionRecordProvider,
ExtendCollectionsProvider,
SchemaComponent, SchemaComponent,
useAPIClient, useAPIClient,
useActionContext, useActionContext,
useCollection,
useCollectionRecordData, useCollectionRecordData,
useDataBlockRequest,
useDataBlockResource,
useRequest, useRequest,
useToken, useToken,
} from '@nocobase/client'; } from '@nocobase/client';
@ -22,32 +40,37 @@ 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 { useForm } from '@formily/react'; import { useForm, useField } from '@formily/react';
import { createForm } from '@formily/core'; import { createForm, Field } 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 { ProfileSettings } from './ProfileSettings';
import { ChatSettings } from './ChatSettings'; import { ChatSettings } from './ChatSettings';
import { AIEmployee } from '../types'; import { AIEmployee } from '../types';
import aiEmployees from '../../../collections/ai-employees';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
const EmployeeContext = createContext(null); const EmployeeContext = createContext(null);
const AIEmployeeForm: React.FC = () => { const AIEmployeeForm: React.FC<{
edit?: boolean;
}> = ({ edit }) => {
return ( return (
<Tabs <Tabs
items={[ items={[
{ {
key: 'profile', key: 'profile',
label: 'Profile', label: 'Profile',
children: <ProfileSettings />, children: <ProfileSettings edit={edit} />,
}, forceRender: true,
{
key: 'chat',
label: 'Chat settings',
children: <ChatSettings />,
}, },
// { // {
// key: 'chat',
// label: 'Chat settings',
// children: <ChatSettings />,
// },
// {
// key: 'skills', // key: 'skills',
// label: 'Skills', // label: 'Skills',
// // children: ( // // children: (
@ -130,6 +153,7 @@ const AIEmployeeForm: React.FC = () => {
key: 'modelSettings', key: 'modelSettings',
label: 'Model Settings', label: 'Model Settings',
children: <ModelSettings />, children: <ModelSettings />,
forceRender: true,
}, },
]} ]}
/> />
@ -182,7 +206,7 @@ const useCreateActionProps = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const form = useForm(); const form = useForm();
const api = useAPIClient(); const api = useAPIClient();
const { refresh } = useContext(EmployeeContext); const { refresh } = useDataBlockRequest();
const t = useT(); const t = useT();
return { return {
@ -205,18 +229,20 @@ const useEditActionProps = () => {
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
const { message } = App.useApp(); const { message } = App.useApp();
const form = useForm(); const form = useForm();
const resource = useDataBlockResource();
const { refresh } = useDataBlockRequest();
const collection = useCollection();
const filterTk = collection.getFilterTargetKey();
const t = useT(); const t = useT();
const { refresh } = useContext(EmployeeContext);
const api = useAPIClient();
return { return {
type: 'primary', type: 'primary',
async onClick() { async onClick() {
await form.submit(); await form.submit();
const values = form.values; const values = form.values;
await api.resource('aiEmployees').update({ await resource.update({
values, values,
filterByTk: values.username, filterByTk: values[filterTk],
}); });
refresh(); refresh();
message.success(t('Saved successfully')); message.success(t('Saved successfully'));
@ -226,59 +252,269 @@ const useEditActionProps = () => {
}; };
}; };
export const Employees: React.FC = () => { // export const Employees: React.FC = () => {
// const t = useT();
// const { message, modal } = App.useApp();
// const { token } = useToken();
// const api = useAPIClient();
// const { data, loading, refresh } = useRequest<AIEmployee[]>(() =>
// api
// .resource('aiEmployees')
// .list()
// .then((res) => res?.data?.data),
// );
//
// const del = (username: string) => {
// modal.confirm({
// title: t('Delete AI employee'),
// content: t('Are you sure to delete this employee?'),
// onOk: async () => {
// await api.resource('aiEmployees').destroy({
// filterByTk: username,
// });
// message.success(t('Deleted successfully'));
// refresh();
// },
// });
// };
//
// return (
// <EmployeeContext.Provider value={{ refresh }}>
// <div
// style={{ marginBottom: token.marginLG }}
// className={css`
// justify-content: space-between;
// display: flex;
// align-items: center;
// `}
// >
// <div>
// <Input allowClear placeholder={t('Search')} />
// </div>
// <div>
// <Space>
// <Button>{t('New from template')}</Button>
// <SchemaComponent
// scope={{ useCreateFormProps, useCancelActionProps, useCreateActionProps }}
// components={{ AIEmployeeForm }}
// schema={{
// type: 'void',
// name: uid(),
// 'x-component': 'Action',
// 'x-component-props': {
// type: 'primary',
// },
// title: 'New AI employee',
// properties: {
// drawer: {
// type: 'void',
// 'x-component': 'Action.Drawer',
// title: 'New AI employee',
// 'x-decorator': 'FormV2',
// 'x-use-decorator-props': 'useCreateFormProps',
// properties: {
// form: {
// type: 'void',
// 'x-component': 'AIEmployeeForm',
// },
// footer: {
// type: 'void',
// 'x-component': 'Action.Drawer.Footer',
// properties: {
// close: {
// title: 'Cancel',
// 'x-component': 'Action',
// 'x-component-props': {
// type: 'default',
// },
// 'x-use-component-props': 'useCancelActionProps',
// },
// submit: {
// title: 'Submit',
// 'x-component': 'Action',
// 'x-component-props': {
// type: 'primary',
// },
// 'x-use-component-props': 'useCreateActionProps',
// },
// },
// },
// },
// },
// },
// }}
// />
// </Space>
// </div>
// </div>
// {loading ? (
// <Spin />
// ) : data && data.length ? (
// <Row gutter={[16, 16]}>
// {data.map((employee) => (
// <CollectionRecordProvider key={employee.username} record={employee}>
// <Col span={6}>
// <Card
// variant="borderless"
// actions={[
// <SchemaComponent
// key="edit"
// scope={{ useCancelActionProps, useEditFormProps, useEditActionProps }}
// components={{ AIEmployeeForm }}
// schema={{
// type: 'void',
// name: uid(),
// 'x-component': 'Action',
// 'x-component-props': {
// component: (props) => <EditOutlined {...props} />,
// },
// properties: {
// drawer: {
// type: 'void',
// 'x-component': 'Action.Drawer',
// title: 'Edit AI employee',
// 'x-decorator': 'FormV2',
// 'x-use-decorator-props': 'useEditFormProps',
// properties: {
// form: {
// type: 'void',
// 'x-component': 'AIEmployeeForm',
// 'x-component-props': {
// edit: true,
// },
// },
// footer: {
// type: 'void',
// 'x-component': 'Action.Drawer.Footer',
// properties: {
// close: {
// title: 'Cancel',
// 'x-component': 'Action',
// 'x-component-props': {
// type: 'default',
// },
// 'x-use-component-props': 'useCancelActionProps',
// },
// submit: {
// title: 'Submit',
// 'x-component': 'Action',
// 'x-component-props': {
// type: 'primary',
// },
// 'x-use-component-props': 'useEditActionProps',
// },
// },
// },
// },
// },
// },
// }}
// />,
// <DeleteOutlined key="delete" onClick={() => del(employee.username)} />,
// ]}
// >
// <Meta
// avatar={employee.avatar ? <Avatar size={40} src={avatars(employee.avatar)} /> : null}
// title={employee.nickname}
// description={
// <>
// {employee.position && (
// <Tag
// style={{
// marginBottom: token.marginXS,
// }}
// >
// {employee.position}
// </Tag>
// )}
// <Typography.Paragraph
// style={{ height: token.fontSize * token.lineHeight * 3 }}
// ellipsis={{ rows: 3 }}
// type="secondary"
// >
// {employee.bio}
// </Typography.Paragraph>
// </>
// }
// />
// </Card>
// </Col>
// </CollectionRecordProvider>
// ))}
// </Row>
// ) : (
// <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
// )}
// </EmployeeContext.Provider>
// );
// };
const Avatar: React.FC = (props) => {
const field = useField<Field>();
if (!field.value) {
return null;
}
return <AntdAvatar {...props} src={avatars(field.value)} />;
};
export const Employees = () => {
const t = useT(); const t = useT();
const { message, modal } = App.useApp();
const { token } = useToken();
const api = useAPIClient();
const { data, loading, refresh } = useRequest<AIEmployee[]>(() =>
api
.resource('aiEmployees')
.list()
.then((res) => res?.data?.data),
);
const del = (username: string) => {
modal.confirm({
title: t('Delete AI employee'),
content: t('Are you sure to delete this employee?'),
onOk: async () => {
await api.resource('aiEmployees').destroy({
filterByTk: username,
});
message.success(t('Deleted successfully'));
refresh();
},
});
};
return ( return (
<EmployeeContext.Provider value={{ refresh }}> <ExtendCollectionsProvider collections={[aiEmployees]}>
<div
style={{ marginBottom: token.marginLG }}
className={css`
justify-content: space-between;
display: flex;
align-items: center;
`}
>
<div>
<Input allowClear placeholder={t('Search')} />
</div>
<div>
<Space>
<Button>{t('New from template')}</Button>
<SchemaComponent <SchemaComponent
scope={{ useCreateFormProps, useCancelActionProps, useCreateActionProps }} components={{ AIEmployeeForm, Avatar }}
components={{ AIEmployeeForm }} scope={{
useCreateFormProps,
useEditFormProps,
useCancelActionProps,
useCreateActionProps,
useEditActionProps,
}}
schema={{ schema={{
type: 'void', type: 'void',
name: uid(), name: 'root',
properties: {
block: {
type: 'void',
'x-component': 'CardItem',
'x-component-props': {
heightMode: 'fullHeight',
},
'x-decorator': 'TableBlockProvider',
'x-decorator-props': {
collection: 'aiEmployees',
action: 'list',
rowKey: 'username',
dragSort: true,
dragSortBy: 'sort',
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 20,
},
},
properties: {
refresh: {
title: "{{t('Refresh')}}",
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
icon: 'ReloadOutlined',
},
},
add: {
type: 'void',
title: "{{t('New AI employee')}}",
'x-align': 'right',
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': { 'x-component-props': {
type: 'primary', type: 'primary',
icon: 'PlusOutlined',
}, },
title: 'New AI employee',
properties: { properties: {
drawer: { drawer: {
type: 'void', type: 'void',
@ -316,62 +552,130 @@ export const Employees: React.FC = () => {
}, },
}, },
}, },
}} },
/> },
</Space> },
</div> table: {
</div> type: 'array',
{loading ? ( 'x-component': 'TableV2',
<Spin /> 'x-use-component-props': 'useTableBlockProps',
) : data && data.length ? (
<Row gutter={[16, 16]}>
{data.map((employee) => (
<CollectionRecordProvider key={employee.username} record={employee}>
<Col span={6}>
<Card
variant="borderless"
actions={[
<SchemaComponent
key="edit"
scope={{ useCancelActionProps, useEditFormProps, useEditActionProps }}
components={{ AIEmployeeForm }}
schema={{
type: 'void',
name: uid(),
'x-component': 'Action',
'x-component-props': { 'x-component-props': {
component: (props) => <EditOutlined {...props} />, rowKey: 'username',
rowSelection: {
type: 'checkbox',
},
},
properties: {
column0: {
type: 'void',
title: t('Avatar'),
'x-component': 'TableV2.Column',
properties: {
avatar: {
type: 'string',
'x-component': 'Avatar',
},
},
},
column1: {
type: 'void',
title: t('Username'),
'x-component': 'TableV2.Column',
properties: {
username: {
type: 'string',
'x-component': 'Input',
'x-pattern': 'readPretty',
},
},
},
column2: {
type: 'void',
title: t('Nickname'),
'x-component': 'TableV2.Column',
properties: {
nickname: {
type: 'string',
'x-component': 'Input',
'x-pattern': 'readPretty',
},
},
},
column3: {
type: 'void',
title: t('Position'),
'x-component': 'TableV2.Column',
properties: {
position: {
type: 'string',
'x-component': 'Input',
'x-pattern': 'readPretty',
},
},
},
column4: {
type: 'void',
title: t('Bio'),
'x-component': 'TableV2.Column',
properties: {
bio: {
type: 'string',
'x-component': 'Input.TextArea',
'x-component-props': {
ellipsis: true,
},
'x-pattern': 'readPretty',
},
},
},
column5: {
type: 'void',
title: 'Actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
edit: {
type: 'void',
title: 'Edit',
'x-action': 'update',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
}, },
properties: { properties: {
drawer: { drawer: {
type: 'void', type: 'void',
title: t('Edit AI employee'),
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
title: 'Edit AI employee',
'x-decorator': 'FormV2', 'x-decorator': 'FormV2',
'x-use-decorator-props': 'useEditFormProps', 'x-use-decorator-props': 'useEditFormProps',
properties: { properties: {
form: { form: {
type: 'void', type: 'void',
'x-component': 'AIEmployeeForm', 'x-component': 'AIEmployeeForm',
'x-component-props': {
edit: true,
},
}, },
footer: { footer: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
properties: { properties: {
close: { close: {
title: 'Cancel', title: t('Cancel'),
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': {
type: 'default',
},
'x-use-component-props': 'useCancelActionProps', 'x-use-component-props': 'useCancelActionProps',
}, },
submit: { submit: {
title: 'Submit', title: 'Submit',
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
'x-use-component-props': 'useEditActionProps', 'x-use-component-props': 'useEditActionProps',
}, },
}, },
@ -379,43 +683,31 @@ export const Employees: React.FC = () => {
}, },
}, },
}, },
},
destroy: {
type: 'void',
title: '{{ t("Delete") }}',
'x-action': 'destroy',
'x-component': 'Action.Link',
'x-use-component-props': 'useDestroyActionProps',
'x-component-props': {
confirm: {
title: "{{t('Delete AI employee')}}",
content: "{{t('Are you sure you want to delete this AI employee?')}}",
},
},
},
},
},
},
},
},
},
},
},
},
}} }}
/>,
<DeleteOutlined key="delete" onClick={() => del(employee.username)} />,
]}
>
<Meta
avatar={employee.avatar ? <Avatar size={40} src={avatars(employee.avatar)} /> : null}
title={employee.nickname}
description={
<>
{employee.position && (
<Tag
style={{
marginBottom: token.marginXS,
}}
>
{employee.position}
</Tag>
)}
<Typography.Paragraph
style={{ height: token.fontSize * token.lineHeight * 3 }}
ellipsis={{ rows: 3 }}
type="secondary"
>
{employee.bio}
</Typography.Paragraph>
</>
}
/> />
</Card> </ExtendCollectionsProvider>
</Col>
</CollectionRecordProvider>
))}
</Row>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</EmployeeContext.Provider>
); );
}; };

View File

@ -12,7 +12,9 @@ import React from 'react';
import { AvatarSelect } from './AvatarSelect'; import { AvatarSelect } from './AvatarSelect';
import { useT } from '../../locale'; import { useT } from '../../locale';
export const ProfileSettings: React.FC = () => { export const ProfileSettings: React.FC<{
edit?: boolean;
}> = ({ edit }) => {
const t = useT(); const t = useT();
return ( return (
<SchemaComponent <SchemaComponent
@ -26,6 +28,7 @@ export const ProfileSettings: React.FC = () => {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
required: true, required: true,
'x-disabled': edit,
}, },
nickname: { nickname: {
type: 'string', type: 'string',

View File

@ -23,7 +23,9 @@ export const useAISelectionContext = () => {
return useContext(AISelectionContext); return useContext(AISelectionContext);
}; };
export const AISelectionProvider: React.FC = (props) => { export const AISelectionProvider: React.FC<{
children: React.ReactNode;
}> = (props) => {
const [selectable, setSelectable] = useState(''); const [selectable, setSelectable] = useState('');
const [selector, setSelector] = useState<Selector>(null); const [selector, setSelector] = useState<Selector>(null);

View File

@ -19,6 +19,7 @@ import { useAISelectionContext } from '../selector/AISelectorProvider';
import { AIEmployee } from '../types'; import { AIEmployee } from '../types';
import { AIVariableRawTextArea } from './AIVariableRawTextArea'; import { AIVariableRawTextArea } from './AIVariableRawTextArea';
import { useFieldSchema } from '@formily/react'; import { useFieldSchema } from '@formily/react';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
const SettingsForm: React.FC<{ const SettingsForm: React.FC<{
form: any; form: any;
@ -150,9 +151,11 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
const t = useT(); const t = useT();
const { dn } = useSchemaSettings(); const { dn } = useSchemaSettings();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {}; const username = dn.getSchemaAttribute('x-component-props.username') || {};
const form = useMemo(() => createForm({}), []); const form = useMemo(() => createForm({}), []);
const { selectable } = useAISelectionContext(); const { selectable } = useAISelectionContext();
const { aiEmployeesMap } = useAIEmployeesContext();
const aiEmployee = aiEmployeesMap[username];
return ( return (
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
@ -176,7 +179,7 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
dn.deepMerge({ dn.deepMerge({
'x-uid': dn.getSchemaAttribute('x-uid'), 'x-uid': dn.getSchemaAttribute('x-uid'),
'x-component-props': { 'x-component-props': {
aiEmployee, username,
message, message,
taskDesc, taskDesc,
autoSend, autoSend,

View File

@ -79,6 +79,5 @@ export type ResendOptions = {
export type ShortcutOptions = { export type ShortcutOptions = {
aiEmployee: AIEmployee; aiEmployee: AIEmployee;
message: { type: MessageType; content: string }; message: { type: MessageType; content: string };
infoFormValues: any;
autoSend: boolean; autoSend: boolean;
}; };

View File

@ -187,7 +187,6 @@ export const llmsSchema = {
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-component-props': { 'x-component-props': {
openMode: 'drawer', openMode: 'drawer',
icon: 'EditOutlined',
}, },
properties: { properties: {
drawer: { drawer: {

View File

@ -13,5 +13,17 @@ import aiEmployees from '../../collections/ai-employees';
export default defineCollection({ export default defineCollection({
migrationRules: ['overwrite', 'schema-only'], migrationRules: ['overwrite', 'schema-only'],
autoGenId: false, autoGenId: false,
sortable: true,
...aiEmployees, ...aiEmployees,
fields: [
...aiEmployees.fields,
{
name: 'userConfigs',
type: 'hasMany',
target: 'usersAiEmployees',
sourceKey: 'username',
foreignKey: 'aiEmployee',
onDelete: 'CASCADE',
},
],
}); });

View File

@ -0,0 +1,22 @@
/**
* 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 { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'usersAiEmployees',
migrationRules: ['schema-only'],
fields: [
{ type: 'sort', name: 'sort' },
{
type: 'text',
name: 'prompt',
},
],
});

View File

@ -0,0 +1,27 @@
/**
* 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 { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'users',
fields: [
{
type: 'belongsToMany',
name: 'aiEmployees',
target: 'aiEmployees',
foreignKey: 'userId',
otherKey: 'aiEmployee',
onDelete: 'CASCADE',
sourceKey: 'id',
targetKey: 'username',
through: 'usersAiEmployees',
},
],
});

View File

@ -17,6 +17,7 @@ import { LLMInstruction } from './workflow/nodes/llm';
import aiConversations from './resource/aiConversations'; import aiConversations from './resource/aiConversations';
import { AIEmployeesManager } from './ai-employees/ai-employees-manager'; import { AIEmployeesManager } from './ai-employees/ai-employees-manager';
import Snowflake from './snowflake'; import Snowflake from './snowflake';
import { listByUser } from './resource/aiEmployees';
export class PluginAIServer extends Plugin { export class PluginAIServer extends Plugin {
aiManager = new AIManager(); aiManager = new AIManager();
@ -40,6 +41,7 @@ export class PluginAIServer extends Plugin {
this.app.resourceManager.define(aiResource); this.app.resourceManager.define(aiResource);
this.app.resourceManager.define(aiConversations); this.app.resourceManager.define(aiConversations);
this.app.resourceManager.registerActionHandler('aiEmployees:listByUser', listByUser);
this.app.acl.registerSnippet({ this.app.acl.registerSnippet({
name: `pm.${this.name}.llm-services`, name: `pm.${this.name}.llm-services`,
actions: ['ai:*', 'llmServices:*'], actions: ['ai:*', 'llmServices:*'],
@ -48,6 +50,7 @@ export class PluginAIServer extends Plugin {
name: `pm.${this.name}.ai-employees`, name: `pm.${this.name}.ai-employees`,
actions: ['aiEmployees:*'], actions: ['aiEmployees:*'],
}); });
this.app.acl.allow('aiEmployees', 'listByUser', 'loggedIn');
this.app.acl.allow('aiConversations', '*', 'loggedIn'); this.app.acl.allow('aiConversations', '*', 'loggedIn');
const workflowSnippet = this.app.acl.snippetManager.snippets.get('pm.workflow.workflows'); const workflowSnippet = this.app.acl.snippetManager.snippets.get('pm.workflow.workflows');
if (workflowSnippet) { if (workflowSnippet) {

View File

@ -0,0 +1,39 @@
/**
* 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 { Context, Next } from '@nocobase/actions';
export const listByUser = async (ctx: Context, next: Next) => {
const user = ctx.auth.user;
const model = ctx.db.getModel('aiEmployees');
const sequelize = ctx.db.sequelize;
const rows = await model.findAll({
include: [
{
model: ctx.db.getModel('usersAiEmployees'),
as: 'userConfigs',
required: false,
where: { userId: user.id },
},
],
order: [
[sequelize.literal('CASE WHEN userConfigs.sort IS NOT NULL THEN 0 ELSE 1 END'), 'ASC'],
[sequelize.fn('COALESCE', sequelize.col('userConfigs.sort'), sequelize.col('aiEmployees.sort')), 'ASC'],
],
});
ctx.body = rows.map((row) => ({
username: row.username,
nickname: row.nickname,
position: row.position,
avatar: row.avatar,
bio: row.bio,
greeting: row.greeting,
}));
await next();
};