mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +08:00
chore: optimize code
This commit is contained in:
parent
c9ed2208ec
commit
e79359c683
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 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 { withDynamicSchemaProps } from '@nocobase/client';
|
||||||
|
import { createContext } from 'react';
|
||||||
|
import { AttachmentProps } from './types';
|
||||||
|
|
||||||
|
export type AIEmployeeChatContext = {
|
||||||
|
attachments?: Record<string, AttachmentProps>;
|
||||||
|
actions?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
action: (aiMessage: string) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
variableScopes?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIEmployeeChatContext = createContext<AIEmployeeChatContext>({} as AIEmployeeChatContext);
|
||||||
|
|
||||||
|
export const AIEmployeeChatProvider: React.FC<AIEmployeeChatContext> = withDynamicSchemaProps((props) => {
|
||||||
|
return <AIEmployeeChatContext.Provider value={props}>{props.children}</AIEmployeeChatContext.Provider>;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAIEmployeeChatContext = () => {
|
||||||
|
return useContext(AIEmployeeChatContext);
|
||||||
|
};
|
@ -11,14 +11,7 @@ import React, { useContext } 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';
|
||||||
|
import { AIEmployee } from './types';
|
||||||
export type AIEmployee = {
|
|
||||||
username: string;
|
|
||||||
nickname?: string;
|
|
||||||
avatar?: string;
|
|
||||||
bio?: string;
|
|
||||||
greeting?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AIEmployeesContext = createContext<{
|
export const AIEmployeesContext = createContext<{
|
||||||
aiEmployees: AIEmployee[];
|
aiEmployees: AIEmployee[];
|
||||||
|
@ -10,25 +10,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import { BuildOutlined } from '@ant-design/icons';
|
import { BuildOutlined } from '@ant-design/icons';
|
||||||
|
import { AttachmentProps } from '../types';
|
||||||
export type AttachmentType = 'image' | 'uiSchema';
|
|
||||||
export type AttachmentProps = {
|
|
||||||
type: AttachmentType;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Attachment: React.FC<
|
export const Attachment: React.FC<
|
||||||
AttachmentProps & {
|
AttachmentProps & {
|
||||||
closeable?: boolean;
|
closeable?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
> = ({ type, content, closeable, onClose }) => {
|
> = ({ type, title, content, closeable, onClose }) => {
|
||||||
let prefix: React.ReactNode;
|
let prefix: React.ReactNode;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'uiSchema':
|
case 'uiSchema':
|
||||||
prefix = (
|
prefix = (
|
||||||
<>
|
<>
|
||||||
<BuildOutlined /> UI Schema {'>'}{' '}
|
<BuildOutlined /> {title} {'>'}{' '}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
@ -7,29 +7,24 @@
|
|||||||
* 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, useEffect, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, Modal, Tag } from 'antd';
|
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, App } from 'antd';
|
||||||
import { Conversations, Sender, Attachments, Bubble } from '@ant-design/x';
|
import { Conversations, Sender, Bubble } from '@ant-design/x';
|
||||||
import type { ConversationsProps } from '@ant-design/x';
|
import type { ConversationsProps } from '@ant-design/x';
|
||||||
import {
|
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
CloseOutlined,
|
import { useAPIClient, useToken } from '@nocobase/client';
|
||||||
ExpandOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
LayoutOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
BuildOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useAPIClient, useRequest, useToken } from '@nocobase/client';
|
|
||||||
import { useT } from '../../locale';
|
import { useT } from '../../locale';
|
||||||
import { ChatBoxContext } from './ChatBoxProvider';
|
|
||||||
const { Header, Footer, Sider, Content } = Layout;
|
const { Header, Footer, Sider, Content } = Layout;
|
||||||
import { avatars } from '../avatars';
|
import { avatars } from '../avatars';
|
||||||
import { AIEmployee, AIEmployeesContext, useAIEmployeesContext } from '../AIEmployeesProvider';
|
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
||||||
import { Attachment } from './Attachment';
|
import { Attachment } from './Attachment';
|
||||||
|
import { ChatBoxContext } from './ChatBoxContext';
|
||||||
|
import { AIEmployee } from '../types';
|
||||||
|
|
||||||
export const ChatBox: React.FC = () => {
|
export const ChatBox: React.FC = () => {
|
||||||
|
const { modal, message } = App.useApp();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const {
|
const {
|
||||||
send,
|
send,
|
||||||
@ -46,6 +41,9 @@ export const ChatBox: React.FC = () => {
|
|||||||
setAttachments,
|
setAttachments,
|
||||||
responseLoading,
|
responseLoading,
|
||||||
senderRef,
|
senderRef,
|
||||||
|
senderValue,
|
||||||
|
setSenderValue,
|
||||||
|
clear,
|
||||||
} = useContext(ChatBoxContext);
|
} = useContext(ChatBoxContext);
|
||||||
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
||||||
const {
|
const {
|
||||||
@ -54,7 +52,7 @@ export const ChatBox: React.FC = () => {
|
|||||||
} = useAIEmployeesContext();
|
} = useAIEmployeesContext();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const [showConversations, setShowConversations] = useState(true);
|
const [showConversations, setShowConversations] = useState(false);
|
||||||
const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])];
|
const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])];
|
||||||
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
||||||
key: conversation.sessionId,
|
key: conversation.sessionId,
|
||||||
@ -66,9 +64,9 @@ export const ChatBox: React.FC = () => {
|
|||||||
await api.resource('aiConversations').destroy({
|
await api.resource('aiConversations').destroy({
|
||||||
filterByTk: sessionId,
|
filterByTk: sessionId,
|
||||||
});
|
});
|
||||||
|
message.success(t('Deleted successfully'));
|
||||||
conversationsService.refresh();
|
conversationsService.refresh();
|
||||||
setCurrentConversation(undefined);
|
clear();
|
||||||
setMessages([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessages = async (sessionId: string) => {
|
const getMessages = async (sessionId: string) => {
|
||||||
@ -132,7 +130,7 @@ export const ChatBox: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
placement="bottomLeft"
|
placement="rightTop"
|
||||||
content={
|
content={
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -185,7 +183,10 @@ export const ChatBox: React.FC = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
${highlight}
|
${highlight}
|
||||||
`}
|
`}
|
||||||
onClick={() => setFilterEmployee(aiEmployee.username)}
|
onClick={() => {
|
||||||
|
clear();
|
||||||
|
setFilterEmployee(aiEmployee.username);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Avatar src={avatars(aiEmployee.avatar)} shape="square" size={40} />
|
<Avatar src={avatars(aiEmployee.avatar)} shape="square" size={40} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -237,7 +238,7 @@ export const ChatBox: React.FC = () => {
|
|||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'delete':
|
case 'delete':
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: t('Delete this conversation?'),
|
title: t('Delete this conversation?'),
|
||||||
content: t('Are you sure to delete this conversation?'),
|
content: t('Are you sure to delete this conversation?'),
|
||||||
onOk: () => deleteConversation(conversation.key),
|
onOk: () => deleteConversation(conversation.key),
|
||||||
@ -279,11 +280,7 @@ export const ChatBox: React.FC = () => {
|
|||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
type="text"
|
type="text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentConversation(undefined);
|
clear();
|
||||||
setMessages([]);
|
|
||||||
senderRef.current?.focus({
|
|
||||||
cursor: 'start',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -301,6 +298,7 @@ export const ChatBox: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
margin: '16px 0',
|
margin: '16px 0',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{messages?.length ? (
|
{messages?.length ? (
|
||||||
@ -314,10 +312,11 @@ export const ChatBox: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
width: '64px',
|
width: '64px',
|
||||||
margin: '0 auto',
|
top: '50%',
|
||||||
marginTop: '50%',
|
left: '50%',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmptyIcon />
|
<EmptyIcon />
|
||||||
@ -331,7 +330,11 @@ export const ChatBox: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sender
|
<Sender
|
||||||
|
value={senderValue}
|
||||||
ref={senderRef}
|
ref={senderRef}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSenderValue(value);
|
||||||
|
}}
|
||||||
onSubmit={(content) =>
|
onSubmit={(content) =>
|
||||||
send({
|
send({
|
||||||
sessionId: currentConversation,
|
sessionId: currentConversation,
|
||||||
|
@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* 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 { AttachmentProps, Conversation, Message, Action, SendOptions, AIEmployee } from '../types';
|
||||||
|
import { Avatar, GetProp, GetRef, Button, Alert, Space } from 'antd';
|
||||||
|
import type { Sender } from '@ant-design/x';
|
||||||
|
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
|
||||||
|
import { Bubble } from '@ant-design/x';
|
||||||
|
import { useAPIClient, useRequest } from '@nocobase/client';
|
||||||
|
import { AIEmployeesContext } from '../AIEmployeesProvider';
|
||||||
|
import { Attachment } from './Attachment';
|
||||||
|
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||||||
|
import { avatars } from '../avatars';
|
||||||
|
import { useChatMessages } from './useChatMessages';
|
||||||
|
|
||||||
|
export const ChatBoxContext = createContext<{
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
open: boolean;
|
||||||
|
filterEmployee: string;
|
||||||
|
setFilterEmployee: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
conversations: {
|
||||||
|
loading: boolean;
|
||||||
|
data?: Conversation[];
|
||||||
|
refresh: () => void;
|
||||||
|
};
|
||||||
|
currentConversation: string;
|
||||||
|
setCurrentConversation: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
|
messages: Message[];
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||||
|
roles: { [role: string]: any };
|
||||||
|
responseLoading: boolean;
|
||||||
|
attachments: AttachmentProps[];
|
||||||
|
setAttachments: React.Dispatch<React.SetStateAction<AttachmentProps[]>>;
|
||||||
|
actions: Action[];
|
||||||
|
setActions: React.Dispatch<React.SetStateAction<Action[]>>;
|
||||||
|
senderValue: string;
|
||||||
|
setSenderValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
|
||||||
|
clear: () => void;
|
||||||
|
send(opts: SendOptions): void;
|
||||||
|
}>({} as any);
|
||||||
|
|
||||||
|
const defaultRoles = {
|
||||||
|
user: {
|
||||||
|
placement: 'end',
|
||||||
|
styles: {
|
||||||
|
content: {
|
||||||
|
maxWidth: '400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant: 'borderless',
|
||||||
|
messageRender: (msg: any) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'text':
|
||||||
|
return <Bubble content={msg.content} />;
|
||||||
|
default:
|
||||||
|
return <Attachment {...msg} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
placement: 'start',
|
||||||
|
variant: 'borderless',
|
||||||
|
messageRender: (msg: any) => {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
{msg.content} <Button icon={<ReloadOutlined />} type="text" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
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 (
|
||||||
|
<Button onClick={msg.onClick} icon={msg.icon}>
|
||||||
|
{msg.content}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
||||||
|
placement: 'start',
|
||||||
|
avatar: aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} /> : null,
|
||||||
|
typing: { step: 5, interval: 20 },
|
||||||
|
style: {
|
||||||
|
maxWidth: 400,
|
||||||
|
marginInlineEnd: 48,
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
footer: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant: 'borderless',
|
||||||
|
messageRender: (msg: any) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'greeting':
|
||||||
|
return <Bubble content={msg.content} />;
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<Bubble
|
||||||
|
content={msg.content}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button color="default" variant="text" size="small" icon={<ReloadOutlined />} />
|
||||||
|
<Button color="default" variant="text" size="small" icon={<CopyOutlined />} />
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useChatBoxContext = () => {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { aiEmployees } = useContext(AIEmployeesContext);
|
||||||
|
const [openChatBox, setOpenChatBox] = useState(false);
|
||||||
|
const [filterEmployee, setFilterEmployee] = useState('all');
|
||||||
|
const [currentConversation, setCurrentConversation] = useState<string>();
|
||||||
|
const { messages, setMessages, attachments, setAttachments, actions, setActions, responseLoading, sendMessages } =
|
||||||
|
useChatMessages();
|
||||||
|
const [senderValue, setSenderValue] = useState<string>('');
|
||||||
|
const senderRef = useRef<GetRef<typeof Sender>>(null);
|
||||||
|
const [roles, setRoles] = useState<GetProp<typeof Bubble.List, 'roles'>>(defaultRoles);
|
||||||
|
const conversations = useRequest<Conversation[]>(
|
||||||
|
() =>
|
||||||
|
api
|
||||||
|
.resource('aiConversations')
|
||||||
|
.list({
|
||||||
|
sort: ['-updatedAt'],
|
||||||
|
...(filterEmployee !== 'all'
|
||||||
|
? {
|
||||||
|
filter: {
|
||||||
|
'aiEmployees.username': filterEmployee,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.then((res) => res?.data?.data),
|
||||||
|
{
|
||||||
|
ready: openChatBox,
|
||||||
|
refreshDeps: [filterEmployee],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setCurrentConversation(undefined);
|
||||||
|
setMessages([]);
|
||||||
|
setAttachments([]);
|
||||||
|
setActions([]);
|
||||||
|
setSenderValue('');
|
||||||
|
senderRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (options: SendOptions) => {
|
||||||
|
setSenderValue('');
|
||||||
|
const { aiEmployee } = options;
|
||||||
|
if (!roles[aiEmployee.username]) {
|
||||||
|
setRoles((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
sendMessages({
|
||||||
|
...options,
|
||||||
|
onConversationCreate: (sessionId: string) => {
|
||||||
|
setCurrentConversation(sessionId);
|
||||||
|
conversations.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aiEmployees) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roles = aiEmployees.reduce((prev, aiEmployee) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
setRoles((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...roles,
|
||||||
|
}));
|
||||||
|
}, [aiEmployees]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openChatBox) {
|
||||||
|
senderRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [openChatBox]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: openChatBox,
|
||||||
|
setOpen: setOpenChatBox,
|
||||||
|
filterEmployee,
|
||||||
|
setFilterEmployee,
|
||||||
|
conversations,
|
||||||
|
currentConversation,
|
||||||
|
setCurrentConversation,
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
roles,
|
||||||
|
responseLoading,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
actions,
|
||||||
|
setActions,
|
||||||
|
senderRef,
|
||||||
|
senderValue,
|
||||||
|
setSenderValue,
|
||||||
|
send,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
};
|
@ -7,315 +7,28 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { FloatButton, Avatar, Typography, GetProp, GetRef, Button } from 'antd';
|
import { FloatButton, Avatar } from 'antd';
|
||||||
import type { BubbleProps, Sender } from '@ant-design/x';
|
import { CurrentUserContext } from '@nocobase/client';
|
||||||
import { Bubble } from '@ant-design/x';
|
|
||||||
import { CurrentUserContext, useAPIClient, useLocalVariables, useRequest, useVariables } 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 { AIEmployee, AIEmployeesContext } from '../AIEmployeesProvider';
|
import { ChatBoxContext, useChatBoxContext } from './ChatBoxContext';
|
||||||
import { avatars } from '../avatars';
|
|
||||||
import { uid } from '@formily/shared';
|
|
||||||
import { useT } from '../../locale';
|
|
||||||
import { Attachment, AttachmentProps, AttachmentType } from './Attachment';
|
|
||||||
const { Paragraph } = Typography;
|
|
||||||
|
|
||||||
type Conversation = {
|
|
||||||
sessionId: string;
|
|
||||||
title: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MessageType = 'text' | AttachmentType;
|
|
||||||
type Message = BubbleProps & { key?: string | number; role?: string };
|
|
||||||
type Action = {
|
|
||||||
content: string;
|
|
||||||
onClick: (content: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SendOptions = {
|
|
||||||
sessionId?: string;
|
|
||||||
greeting?: boolean;
|
|
||||||
aiEmployee?: AIEmployee;
|
|
||||||
messages: {
|
|
||||||
type: MessageType;
|
|
||||||
content: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
|
||||||
placement: 'start',
|
|
||||||
avatar: aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} /> : null,
|
|
||||||
typing: { step: 5, interval: 20 },
|
|
||||||
style: {
|
|
||||||
maxWidth: 400,
|
|
||||||
marginInlineEnd: 48,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
footer: {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variant: 'borderless',
|
|
||||||
messageRender: (msg: any) => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'text':
|
|
||||||
return <Bubble content={msg.content} />;
|
|
||||||
case 'action':
|
|
||||||
return <Button onClick={msg.onClick}>{msg.content}</Button>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ChatBoxContext = createContext<{
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
open: boolean;
|
|
||||||
filterEmployee: string;
|
|
||||||
setFilterEmployee: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
conversations: {
|
|
||||||
loading: boolean;
|
|
||||||
data?: Conversation[];
|
|
||||||
refresh: () => void;
|
|
||||||
};
|
|
||||||
currentConversation: string;
|
|
||||||
setCurrentConversation: React.Dispatch<React.SetStateAction<string | undefined>>;
|
|
||||||
messages: Message[];
|
|
||||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
|
||||||
roles: { [role: string]: any };
|
|
||||||
responseLoading: boolean;
|
|
||||||
attachments: AttachmentProps[];
|
|
||||||
setAttachments: React.Dispatch<React.SetStateAction<AttachmentProps[]>>;
|
|
||||||
actions: Action[];
|
|
||||||
setActions: React.Dispatch<React.SetStateAction<Action[]>>;
|
|
||||||
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
|
|
||||||
send(opts: SendOptions): void;
|
|
||||||
}>({} as any);
|
|
||||||
|
|
||||||
export const ChatBoxProvider: React.FC<{
|
export const ChatBoxProvider: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const t = useT();
|
const currentUserCtx = useContext(CurrentUserContext);
|
||||||
const api = useAPIClient();
|
const chatBoxCtx = useChatBoxContext();
|
||||||
const ctx = useContext(CurrentUserContext);
|
const { open, setOpen } = chatBoxCtx;
|
||||||
const { aiEmployees } = useContext(AIEmployeesContext);
|
|
||||||
const [openChatBox, setOpenChatBox] = useState(false);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [filterEmployee, setFilterEmployee] = useState('all');
|
|
||||||
const [currentConversation, setCurrentConversation] = useState<string>();
|
|
||||||
const [responseLoading, setResponseLoading] = useState(false);
|
|
||||||
const [attachments, setAttachments] = useState<AttachmentProps[]>([]);
|
|
||||||
const [actions, setActions] = useState<Action[]>([]);
|
|
||||||
const senderRef = useRef<GetRef<typeof Sender>>(null);
|
|
||||||
const [roles, setRoles] = useState<GetProp<typeof Bubble.List, 'roles'>>({
|
|
||||||
user: {
|
|
||||||
placement: 'end',
|
|
||||||
styles: {
|
|
||||||
content: {
|
|
||||||
maxWidth: '400px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variant: 'borderless',
|
|
||||||
messageRender: (msg: any) => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'text':
|
|
||||||
return <Bubble content={msg.content} />;
|
|
||||||
default:
|
|
||||||
return <Attachment {...msg} />;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const conversations = useRequest<Conversation[]>(
|
|
||||||
() =>
|
|
||||||
api
|
|
||||||
.resource('aiConversations')
|
|
||||||
.list({
|
|
||||||
sort: ['-updatedAt'],
|
|
||||||
...(filterEmployee !== 'all'
|
|
||||||
? {
|
|
||||||
filter: {
|
|
||||||
'aiEmployees.username': filterEmployee,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
.then((res) => res?.data?.data),
|
|
||||||
{
|
|
||||||
ready: openChatBox,
|
|
||||||
refreshDeps: [filterEmployee],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const send = async ({ sessionId, aiEmployee, messages: sendMsgs, greeting }: SendOptions) => {
|
|
||||||
setRoles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
|
||||||
}));
|
|
||||||
const msgs: Message[] = [];
|
|
||||||
if (greeting) {
|
|
||||||
msgs.push({
|
|
||||||
key: uid(),
|
|
||||||
role: aiEmployee.username,
|
|
||||||
content: {
|
|
||||||
type: 'text',
|
|
||||||
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setMessages(msgs);
|
|
||||||
}
|
|
||||||
if (!sendMsgs.length) {
|
|
||||||
senderRef.current?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (attachments.length) {
|
|
||||||
msgs.push(
|
|
||||||
...attachments.map((attachment) => ({
|
|
||||||
key: uid(),
|
|
||||||
role: 'user',
|
|
||||||
content: attachment,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
setMessages(msgs);
|
|
||||||
}
|
|
||||||
msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg })));
|
|
||||||
setMessages(msgs);
|
|
||||||
if (!sessionId) {
|
|
||||||
const createRes = await api.resource('aiConversations').create({
|
|
||||||
values: {
|
|
||||||
aiEmployees: [aiEmployee],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const conversation = createRes?.data?.data;
|
|
||||||
if (!conversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sessionId = conversation.sessionId;
|
|
||||||
setCurrentConversation(conversation.sessionId);
|
|
||||||
conversations.refresh();
|
|
||||||
}
|
|
||||||
setAttachments([]);
|
|
||||||
setResponseLoading(true);
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
key: uid(),
|
|
||||||
role: aiEmployee.username,
|
|
||||||
content: {
|
|
||||||
type: 'text',
|
|
||||||
content: '',
|
|
||||||
},
|
|
||||||
loading: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const sendRes = await api.request({
|
|
||||||
url: 'aiConversations:sendMessages',
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/event-stream',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
aiEmployee: aiEmployee.username,
|
|
||||||
sessionId,
|
|
||||||
messages: msgs,
|
|
||||||
},
|
|
||||||
responseType: 'stream',
|
|
||||||
adapter: 'fetch',
|
|
||||||
});
|
|
||||||
if (!sendRes?.data) {
|
|
||||||
setResponseLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = sendRes.data.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let result = '';
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
let content = '';
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
setResponseLoading(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split('\n').filter(Boolean);
|
|
||||||
for (const line of lines) {
|
|
||||||
const data = JSON.parse(line.replace(/^data: /, ''));
|
|
||||||
if (data.type === 'content' && data.body) {
|
|
||||||
content += data.body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result += content;
|
|
||||||
setMessages((prev) => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
// @ts-ignore
|
|
||||||
last.content.content = last.content.content + content;
|
|
||||||
last.loading = false;
|
|
||||||
return [...prev];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (actions) {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
...actions.map((action) => ({
|
|
||||||
key: uid(),
|
|
||||||
role: aiEmployee.username,
|
|
||||||
content: {
|
|
||||||
type: 'action',
|
|
||||||
content: action.content,
|
|
||||||
onClick: () => {
|
|
||||||
action.onClick(result);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
setActions([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!currentUserCtx?.data?.data) {
|
||||||
if (!aiEmployees) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const roles = aiEmployees.reduce((prev, aiEmployee) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
|
||||||
};
|
|
||||||
}, {});
|
|
||||||
setRoles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...roles,
|
|
||||||
}));
|
|
||||||
}, [aiEmployees]);
|
|
||||||
|
|
||||||
if (!ctx?.data?.data) {
|
|
||||||
return <>{props.children}</>;
|
return <>{props.children}</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ChatBoxContext.Provider
|
<ChatBoxContext.Provider value={chatBoxCtx}>
|
||||||
value={{
|
|
||||||
open: openChatBox,
|
|
||||||
setOpen: setOpenChatBox,
|
|
||||||
filterEmployee,
|
|
||||||
setFilterEmployee,
|
|
||||||
conversations,
|
|
||||||
currentConversation,
|
|
||||||
setCurrentConversation,
|
|
||||||
messages,
|
|
||||||
setMessages,
|
|
||||||
roles,
|
|
||||||
responseLoading,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
actions,
|
|
||||||
setActions,
|
|
||||||
senderRef,
|
|
||||||
send,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
{!openChatBox && (
|
{!open && (
|
||||||
<div
|
<div
|
||||||
className={css`
|
className={css`
|
||||||
.ant-float-btn {
|
.ant-float-btn {
|
||||||
@ -340,13 +53,13 @@ export const ChatBoxProvider: React.FC<{
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenChatBox(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
shape="square"
|
shape="square"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{openChatBox ? <ChatBox /> : null}
|
{open ? <ChatBox /> : null}
|
||||||
</ChatBoxContext.Provider>
|
</ChatBoxContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useAPIClient } from '@nocobase/client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Action, AttachmentProps, Message, SendOptions } from '../types';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { useT } from '../../locale';
|
||||||
|
|
||||||
|
export const useChatMessages = () => {
|
||||||
|
const t = useT();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [responseLoading, setResponseLoading] = useState(false);
|
||||||
|
const [attachments, setAttachments] = useState<AttachmentProps[]>([]);
|
||||||
|
const [actions, setActions] = useState<Action[]>([]);
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
setMessages([]);
|
||||||
|
setAttachments([]);
|
||||||
|
setActions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMessage = (message: Message) => {
|
||||||
|
setMessages((prev) => [...prev, message]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMessages = (newMessages: Message[]) => {
|
||||||
|
setMessages((prev) => [...prev, ...newMessages]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLastMessage = (updater: (message: Message) => Message) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const lastIndex = prev.length - 1;
|
||||||
|
if (lastIndex < 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[lastIndex] = updater(updated[lastIndex]);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processStreamResponse = async (stream: any) => {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let result = '';
|
||||||
|
let error = false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
let content = '';
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || error) {
|
||||||
|
setResponseLoading(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.replace(/^data: /, ''));
|
||||||
|
if (data.body) {
|
||||||
|
content += data.body;
|
||||||
|
}
|
||||||
|
if (data.type === 'error') {
|
||||||
|
error = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing stream data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += content;
|
||||||
|
|
||||||
|
// Update the last message with the new content
|
||||||
|
updateLastMessage((last) => {
|
||||||
|
// @ts-ignore
|
||||||
|
last.content.content = last.content.content + content;
|
||||||
|
last.loading = false;
|
||||||
|
return last;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
updateLastMessage((last) => {
|
||||||
|
last.role = 'error';
|
||||||
|
last.loading = false;
|
||||||
|
// @ts-ignore
|
||||||
|
last.content.content = t(result);
|
||||||
|
return last;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessages = async ({
|
||||||
|
sessionId,
|
||||||
|
aiEmployee,
|
||||||
|
messages: sendMsgs,
|
||||||
|
greeting,
|
||||||
|
onConversationCreate,
|
||||||
|
}: SendOptions & {
|
||||||
|
onConversationCreate?: (sessionId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const msgs: Message[] = [];
|
||||||
|
if (greeting) {
|
||||||
|
msgs.push({
|
||||||
|
key: uid(),
|
||||||
|
role: aiEmployee.username,
|
||||||
|
content: {
|
||||||
|
type: 'greeting',
|
||||||
|
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!sendMsgs.length) {
|
||||||
|
addMessages(msgs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachments.length) {
|
||||||
|
msgs.push(
|
||||||
|
...attachments.map((attachment) => ({
|
||||||
|
key: uid(),
|
||||||
|
role: 'user',
|
||||||
|
content: attachment,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg })));
|
||||||
|
addMessages(msgs);
|
||||||
|
if (!sessionId) {
|
||||||
|
const createRes = await api.resource('aiConversations').create({
|
||||||
|
values: {
|
||||||
|
aiEmployees: [aiEmployee],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const conversation = createRes?.data?.data;
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionId = conversation.sessionId;
|
||||||
|
onConversationCreate?.(sessionId);
|
||||||
|
}
|
||||||
|
setAttachments([]);
|
||||||
|
setResponseLoading(true);
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
key: uid(),
|
||||||
|
role: aiEmployee.username,
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
const sendRes = await api.request({
|
||||||
|
url: 'aiConversations:sendMessages',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
aiEmployee: aiEmployee.username,
|
||||||
|
sessionId,
|
||||||
|
messages: msgs,
|
||||||
|
},
|
||||||
|
responseType: 'stream',
|
||||||
|
adapter: 'fetch',
|
||||||
|
});
|
||||||
|
if (!sendRes?.data) {
|
||||||
|
setResponseLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { result, error } = await processStreamResponse(sendRes.data);
|
||||||
|
if (actions.length && !error) {
|
||||||
|
addMessages(
|
||||||
|
actions.map((action) => ({
|
||||||
|
key: uid(),
|
||||||
|
role: 'action',
|
||||||
|
content: {
|
||||||
|
type: 'action',
|
||||||
|
icon: action.icon,
|
||||||
|
content: action.content,
|
||||||
|
onClick: () => {
|
||||||
|
action.onClick(result);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setActions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
actions,
|
||||||
|
setActions,
|
||||||
|
responseLoading,
|
||||||
|
sendMessages,
|
||||||
|
clearMessages,
|
||||||
|
};
|
||||||
|
};
|
@ -10,8 +10,6 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { Avatar, Tag, Popover, Divider, Button } from 'antd';
|
import { Avatar, Tag, Popover, Divider, Button } from 'antd';
|
||||||
import { avatars } from '../avatars';
|
import { avatars } from '../avatars';
|
||||||
import { ChatBoxContext } from '../chatbox/ChatBoxProvider';
|
|
||||||
import { AIEmployee } from '../AIEmployeesProvider';
|
|
||||||
import {
|
import {
|
||||||
SortableItem,
|
SortableItem,
|
||||||
useBlockContext,
|
useBlockContext,
|
||||||
@ -19,26 +17,64 @@ import {
|
|||||||
useSchemaToolbarRender,
|
useSchemaToolbarRender,
|
||||||
useToken,
|
useToken,
|
||||||
useVariables,
|
useVariables,
|
||||||
|
withDynamicSchemaProps,
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import { useFieldSchema } from '@formily/react';
|
import { useFieldSchema } from '@formily/react';
|
||||||
import { useT } from '../../locale';
|
import { useT } from '../../locale';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useForm } from '@formily/react';
|
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
|
||||||
|
import { ChatBoxContext } from '../chatbox/ChatBoxContext';
|
||||||
|
import { AIEmployee } from '../types';
|
||||||
|
|
||||||
|
async function replaceVariables(template, variables, localVariables = {}) {
|
||||||
|
const regex = /\{\{\s*(.*?)\s*\}\}/g;
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
const matches = [...template.matchAll(regex)];
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const fullMatch = match[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let value = await variables?.parseVariable(fullMatch, localVariables).then(({ value }) => value);
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
try {
|
||||||
|
value = JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
result = result.replace(fullMatch, value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const AIEmployeeButton: React.FC<{
|
export const AIEmployeeButton: React.FC<{
|
||||||
aiEmployee: AIEmployee;
|
aiEmployee: AIEmployee;
|
||||||
extraInfo?: string;
|
taskDesc?: string;
|
||||||
}> = ({ aiEmployee, extraInfo }) => {
|
attachments: string[];
|
||||||
|
actions: string[];
|
||||||
|
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, attachments: selectedAttachments, actions: selectedActions }) => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { setOpen, send, setAttachments, setFilterEmployee, setCurrentConversation, setActions } =
|
const { setOpen, send, setAttachments, setFilterEmployee, setActions, clear } = useContext(ChatBoxContext);
|
||||||
useContext(ChatBoxContext);
|
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
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 { name: blockType } = useBlockContext() || {};
|
const { attachments, actions } = useAIEmployeeChatContext();
|
||||||
const form = useForm();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem
|
<SortableItem
|
||||||
@ -46,43 +82,42 @@ export const AIEmployeeButton: React.FC<{
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
clear();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setCurrentConversation(undefined);
|
|
||||||
setFilterEmployee(aiEmployee.username);
|
setFilterEmployee(aiEmployee.username);
|
||||||
setAttachments([]);
|
if (selectedAttachments && selectedAttachments.length) {
|
||||||
setActions([]);
|
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 messages = [];
|
||||||
if (blockType === 'form') {
|
const message = fieldSchema['x-component-props']?.message;
|
||||||
console.log(fieldSchema.parent.parent.toJSON());
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
type: 'uiSchema',
|
|
||||||
content: fieldSchema.parent.parent['x-uid'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setActions([
|
|
||||||
{
|
|
||||||
content: 'Fill form',
|
|
||||||
onClick: (content) => {
|
|
||||||
try {
|
|
||||||
const values = content.replace('```json', '').replace('```', '');
|
|
||||||
form.setValues(JSON.parse(values));
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
let message = fieldSchema['x-component-props']?.message;
|
|
||||||
if (message) {
|
if (message) {
|
||||||
message = await variables
|
const content = await replaceVariables(message.content, variables, localVariables);
|
||||||
?.parseVariable(fieldSchema['x-component-props']?.message, localVariables)
|
|
||||||
.then(({ value }) => value);
|
|
||||||
messages.push({
|
messages.push({
|
||||||
type: 'text',
|
type: message.type || 'text',
|
||||||
content: message,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
send({
|
send({
|
||||||
@ -134,7 +169,7 @@ export const AIEmployeeButton: React.FC<{
|
|||||||
{t('Bio')}
|
{t('Bio')}
|
||||||
</Divider>
|
</Divider>
|
||||||
<p>{aiEmployee.bio}</p>
|
<p>{aiEmployee.bio}</p>
|
||||||
{extraInfo && (
|
{taskDesc && (
|
||||||
<>
|
<>
|
||||||
<Divider
|
<Divider
|
||||||
orientation="left"
|
orientation="left"
|
||||||
@ -143,9 +178,9 @@ export const AIEmployeeButton: React.FC<{
|
|||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('Extra information')}
|
{t('Task description')}
|
||||||
</Divider>
|
</Divider>
|
||||||
<p>{extraInfo}</p>
|
<p>{taskDesc}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -164,4 +199,4 @@ export const AIEmployeeButton: React.FC<{
|
|||||||
{render()}
|
{render()}
|
||||||
</SortableItem>
|
</SortableItem>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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 { SchemaInitializerItem, useSchemaInitializer } from '@nocobase/client';
|
||||||
|
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||||
|
import { Spin, Avatar } from 'antd';
|
||||||
|
import { avatars } from '../avatars';
|
||||||
|
|
||||||
|
const getAIEmployeesInitializer = (dynamicChatContextHook: string) => ({
|
||||||
|
name: 'aiEmployees',
|
||||||
|
title: 'AI employees',
|
||||||
|
type: 'subMenu',
|
||||||
|
useChildren() {
|
||||||
|
const {
|
||||||
|
aiEmployees,
|
||||||
|
service: { loading },
|
||||||
|
} = useAIEmployeesContext();
|
||||||
|
|
||||||
|
return loading
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'spin',
|
||||||
|
Component: () => <Spin />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: aiEmployees.map((aiEmployee) => ({
|
||||||
|
name: aiEmployee.username,
|
||||||
|
Component: () => {
|
||||||
|
const { insert } = useSchemaInitializer();
|
||||||
|
const handleClick = () => {
|
||||||
|
insert({
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'AIEmployeeChatProvider',
|
||||||
|
'x-use-decorator-props': dynamicChatContextHook,
|
||||||
|
'x-component': 'AIEmployeeButton',
|
||||||
|
'x-toolbar': 'ActionSchemaToolbar',
|
||||||
|
'x-settings': 'aiEmployees:button',
|
||||||
|
'x-component-props': {
|
||||||
|
aiEmployee,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<SchemaInitializerItem
|
||||||
|
onClick={handleClick}
|
||||||
|
title={aiEmployee.nickname}
|
||||||
|
icon={
|
||||||
|
<Avatar
|
||||||
|
style={{
|
||||||
|
marginBottom: '5px',
|
||||||
|
}}
|
||||||
|
src={avatars(aiEmployee.avatar)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const detailsAIEmployeesInitializer = getAIEmployeesInitializer('useDetailsAIEmployeeChatContext');
|
||||||
|
export const formAIEmployeesInitializer = getAIEmployeesInitializer('useFormAIEmployeeChatContext');
|
@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { SchemaInitializer, useAPIClient, useRequest, useSchemaInitializer } from '@nocobase/client';
|
|
||||||
import { ReactComponent as DesignIcon } from '../design-icon.svg';
|
|
||||||
import { AIEmployee, AIEmployeesContext, useAIEmployeesContext } from '../AIEmployeesProvider';
|
|
||||||
import { Spin, Avatar } from 'antd';
|
|
||||||
import { avatars } from '../avatars';
|
|
||||||
|
|
||||||
export const configureAIEmployees = new SchemaInitializer({
|
|
||||||
name: 'aiEmployees:configure',
|
|
||||||
title: '{{t("AI employees")}}',
|
|
||||||
icon: (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
display: 'inline-flex',
|
|
||||||
verticalAlign: 'top',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DesignIcon />
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
style: {
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'ai-employees',
|
|
||||||
type: 'itemGroup',
|
|
||||||
useChildren() {
|
|
||||||
const {
|
|
||||||
aiEmployees,
|
|
||||||
service: { loading },
|
|
||||||
} = useAIEmployeesContext();
|
|
||||||
|
|
||||||
return loading
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'spin',
|
|
||||||
Component: () => <Spin />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: aiEmployees.map((aiEmployee) => ({
|
|
||||||
name: aiEmployee.username,
|
|
||||||
title: aiEmployee.nickname,
|
|
||||||
icon: <Avatar src={avatars(aiEmployee.avatar)} />,
|
|
||||||
type: 'item',
|
|
||||||
useComponentProps() {
|
|
||||||
const { insert } = useSchemaInitializer();
|
|
||||||
const handleClick = () => {
|
|
||||||
insert({
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'AIEmployeeButton',
|
|
||||||
'x-toolbar': 'ActionSchemaToolbar',
|
|
||||||
'x-settings': 'aiEmployees:button',
|
|
||||||
'x-component-props': {
|
|
||||||
aiEmployee,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onClick: handleClick,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 } from 'antd';
|
import { Card, Row, Col, Avatar, Input, Space, Button, Tabs, App, Spin, Empty, Typography } from 'antd';
|
||||||
import {
|
import {
|
||||||
CollectionRecordProvider,
|
CollectionRecordProvider,
|
||||||
SchemaComponent,
|
SchemaComponent,
|
||||||
@ -311,6 +311,7 @@ const useEditActionProps = () => {
|
|||||||
|
|
||||||
export const Employees: React.FC = () => {
|
export const Employees: React.FC = () => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const { data, loading, refresh } = useRequest<
|
const { data, loading, refresh } = useRequest<
|
||||||
@ -327,6 +328,20 @@ export const Employees: React.FC = () => {
|
|||||||
.then((res) => res?.data?.data),
|
.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 }}>
|
<EmployeeContext.Provider value={{ refresh }}>
|
||||||
<div
|
<div
|
||||||
@ -456,13 +471,21 @@ export const Employees: React.FC = () => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
<DeleteOutlined key="delete" />,
|
<DeleteOutlined key="delete" onClick={() => del(employee.username)} />,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Meta
|
<Meta
|
||||||
avatar={employee.avatar ? <Avatar src={avatars(employee.avatar)} /> : null}
|
avatar={employee.avatar ? <Avatar src={avatars(employee.avatar)} /> : null}
|
||||||
title={employee.nickname}
|
title={employee.nickname}
|
||||||
description={employee.bio}
|
description={
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{ height: token.fontSize * token.lineHeight * 3 }}
|
||||||
|
ellipsis={{ rows: 3 }}
|
||||||
|
type="secondary"
|
||||||
|
>
|
||||||
|
{employee.bio}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -19,9 +19,10 @@ 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 } from 'antd';
|
import { Card, Avatar, Tooltip } from 'antd';
|
||||||
const { Meta } = Card;
|
const { Meta } = Card;
|
||||||
import { Schema } from '@formily/react';
|
import { Schema } from '@formily/react';
|
||||||
|
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
|
||||||
|
|
||||||
export const useAIEmployeeButtonVariableOptions = () => {
|
export const useAIEmployeeButtonVariableOptions = () => {
|
||||||
const collection = useCollection();
|
const collection = useCollection();
|
||||||
@ -57,6 +58,31 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const { dn } = useSchemaSettings();
|
const { dn } = useSchemaSettings();
|
||||||
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
|
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
|
||||||
|
const { attachments = [], actions = [] } = useAIEmployeeChatContext();
|
||||||
|
const attachmentsOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(attachments).map(([name, item]) => ({
|
||||||
|
label: (
|
||||||
|
<Tooltip title={item.description} placement="right">
|
||||||
|
{item.title}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
value: name,
|
||||||
|
})),
|
||||||
|
[attachments],
|
||||||
|
);
|
||||||
|
const actionsOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(actions).map(([name, item]) => ({
|
||||||
|
label: (
|
||||||
|
<Tooltip title={item.description} placement="right">
|
||||||
|
{item.title}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
value: name,
|
||||||
|
})),
|
||||||
|
[actions],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<SchemaSettingsModalItem
|
<SchemaSettingsModalItem
|
||||||
scope={{ useAIEmployeeButtonVariableOptions }}
|
scope={{ useAIEmployeeButtonVariableOptions }}
|
||||||
@ -68,46 +94,130 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
|
|||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': () => (
|
'x-component': () => (
|
||||||
<Card variant="borderless">
|
<Card
|
||||||
|
variant="borderless"
|
||||||
|
style={{
|
||||||
|
maxWidth: 520,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Meta
|
<Meta
|
||||||
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} /> : null}
|
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} size={48} /> : null}
|
||||||
title={aiEmployee.nickname}
|
title={aiEmployee.nickname}
|
||||||
description={aiEmployee.bio}
|
description={aiEmployee.bio}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
message: {
|
taskDesc: {
|
||||||
|
type: 'string',
|
||||||
|
title: t('Task description'),
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input.TextArea',
|
||||||
|
description: t(
|
||||||
|
'Displays the AI employee’s assigned tasks on the profile when hovering over the button.',
|
||||||
|
),
|
||||||
|
default: dn.getSchemaAttribute('x-component-props.taskDesc'),
|
||||||
|
},
|
||||||
|
manualMessage: {
|
||||||
|
type: 'boolean',
|
||||||
|
'x-content': t('Requires the user to enter a message manually.'),
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
default: dn.getSchemaAttribute('x-component-props.manualMessage') || false,
|
||||||
|
},
|
||||||
|
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',
|
type: 'string',
|
||||||
title: t('Message'),
|
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Variable.RawTextArea',
|
'x-component': 'Variable.RawTextArea',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
scope: '{{ useAIEmployeeButtonVariableOptions }}',
|
scope: '{{ useAIEmployeeButtonVariableOptions }}',
|
||||||
|
changeOnSelect: true,
|
||||||
fieldNames: {
|
fieldNames: {
|
||||||
value: 'name',
|
value: 'name',
|
||||||
label: 'title',
|
label: 'title',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: dn.getSchemaAttribute('x-component-props.message'),
|
|
||||||
},
|
},
|
||||||
extraInfo: {
|
},
|
||||||
type: 'string',
|
default: dn.getSchemaAttribute('x-component-props.message'),
|
||||||
title: t('Extra Information'),
|
'x-reactions': {
|
||||||
|
dependencies: ['.manualMessage'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{ !$deps[0] }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
type: 'array',
|
||||||
|
title: t('Attachments'),
|
||||||
|
'x-component': 'Checkbox.Group',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input.TextArea',
|
enum: attachmentsOptions,
|
||||||
default: dn.getSchemaAttribute('x-component-props.extraInfo'),
|
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')}
|
title={t('Edit')}
|
||||||
onSubmit={({ message, extraInfo }) => {
|
onSubmit={({ message, taskDesc, manualMessage, attachments, actions }) => {
|
||||||
dn.deepMerge({
|
dn.deepMerge({
|
||||||
'x-uid': dn.getSchemaAttribute('x-uid'),
|
'x-uid': dn.getSchemaAttribute('x-uid'),
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
aiEmployee,
|
aiEmployee,
|
||||||
message,
|
message,
|
||||||
extraInfo,
|
taskDesc,
|
||||||
|
manualMessage,
|
||||||
|
attachments,
|
||||||
|
actions,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { BubbleProps } from '@ant-design/x';
|
||||||
|
|
||||||
|
export type AIEmployee = {
|
||||||
|
username: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
greeting?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Conversation = {
|
||||||
|
sessionId: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentType = 'image' | 'uiSchema';
|
||||||
|
export type AttachmentProps = {
|
||||||
|
type: AttachmentType;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageType = 'text' | AttachmentType;
|
||||||
|
export type Message = BubbleProps & { key?: string | number; role?: string };
|
||||||
|
export type Action = {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
content: string;
|
||||||
|
onClick: (content: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
greeting?: boolean;
|
||||||
|
aiEmployee?: AIEmployee;
|
||||||
|
messages: {
|
||||||
|
type: MessageType;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
};
|
@ -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 from 'react';
|
||||||
|
import { useFieldSchema, useForm } from '@formily/react';
|
||||||
|
import { AIEmployeeChatContext } from './AIEmployeeChatProvider';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
import { useT } from '../locale';
|
||||||
|
|
||||||
|
export const useDetailsAIEmployeeChatContext = () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFormAIEmployeeChatContext = (): AIEmployeeChatContext => {
|
||||||
|
const t = useT();
|
||||||
|
const fieldSchema = useFieldSchema();
|
||||||
|
const form = useForm();
|
||||||
|
return {
|
||||||
|
attachments: {
|
||||||
|
formSchema: {
|
||||||
|
title: t('Current form'),
|
||||||
|
type: 'uiSchema',
|
||||||
|
description: 'The JSON schema of the form',
|
||||||
|
content: fieldSchema.parent.parent['x-uid'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setFormValues: {
|
||||||
|
title: t('Set form values'),
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
action: (content: string) => {
|
||||||
|
try {
|
||||||
|
form.setValues(JSON.parse(content));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -16,9 +16,14 @@ import { LLMInstruction } from './workflow/nodes/llm';
|
|||||||
import { AIEmployeeInstruction } from './workflow/nodes/employee';
|
import { AIEmployeeInstruction } from './workflow/nodes/employee';
|
||||||
import { tval } from '@nocobase/utils/client';
|
import { tval } from '@nocobase/utils/client';
|
||||||
import { namespace } from './locale';
|
import { namespace } from './locale';
|
||||||
import { configureAIEmployees } from './ai-employees/initializer/ConfigureAIEmployees';
|
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';
|
||||||
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
|
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
|
||||||
|
const { AIEmployeeChatProvider } = lazy(
|
||||||
|
() => import('./ai-employees/AIEmployeeChatProvider'),
|
||||||
|
'AIEmployeeChatProvider',
|
||||||
|
);
|
||||||
const { Employees } = lazy(() => import('./ai-employees/manager/Employees'), 'Employees');
|
const { Employees } = lazy(() => import('./ai-employees/manager/Employees'), 'Employees');
|
||||||
const { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices');
|
const { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices');
|
||||||
const { MessagesSettings } = lazy(() => import('./chat-settings/Messages'), 'MessagesSettings');
|
const { MessagesSettings } = lazy(() => import('./chat-settings/Messages'), 'MessagesSettings');
|
||||||
@ -39,6 +44,11 @@ export class PluginAIClient extends Plugin {
|
|||||||
this.app.use(AIEmployeesProvider);
|
this.app.use(AIEmployeesProvider);
|
||||||
this.app.addComponents({
|
this.app.addComponents({
|
||||||
AIEmployeeButton,
|
AIEmployeeButton,
|
||||||
|
AIEmployeeChatProvider,
|
||||||
|
});
|
||||||
|
this.app.addScopes({
|
||||||
|
useDetailsAIEmployeeChatContext,
|
||||||
|
useFormAIEmployeeChatContext,
|
||||||
});
|
});
|
||||||
this.app.pluginSettingsManager.add('ai', {
|
this.app.pluginSettingsManager.add('ai', {
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
@ -58,7 +68,16 @@ export class PluginAIClient extends Plugin {
|
|||||||
Component: LLMServices,
|
Component: LLMServices,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.schemaInitializerManager.add(configureAIEmployees);
|
this.app.schemaInitializerManager.addItem(
|
||||||
|
'details:configureActions',
|
||||||
|
'enableActions.aiEmployees',
|
||||||
|
detailsAIEmployeesInitializer,
|
||||||
|
);
|
||||||
|
this.app.schemaInitializerManager.addItem(
|
||||||
|
'createForm:configureActions',
|
||||||
|
'enableActions.aiEmployees',
|
||||||
|
formAIEmployeesInitializer,
|
||||||
|
);
|
||||||
this.app.schemaSettingsManager.add(aiEmployeeButtonSettings);
|
this.app.schemaSettingsManager.add(aiEmployeeButtonSettings);
|
||||||
|
|
||||||
this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
|
this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
|
||||||
|
@ -13,7 +13,7 @@ import { useApp } from '@nocobase/client';
|
|||||||
|
|
||||||
export const namespace = pkg.name;
|
export const namespace = pkg.name;
|
||||||
|
|
||||||
export function useT() {
|
export function useT(): any {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
return (str: string, options?: any) => app.i18n.t(str, { ns: [pkg.name, 'client'], ...options });
|
return (str: string, options?: any) => app.i18n.t(str, { ns: [pkg.name, 'client'], ...options });
|
||||||
}
|
}
|
||||||
|
@ -22,5 +22,6 @@
|
|||||||
"Temperature description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
|
"Temperature description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
|
||||||
"Top P description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.",
|
"Top P description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.",
|
||||||
"Get models list failed, you can enter a model name manually.": "Get models list failed, you can enter a model name manually.",
|
"Get models list failed, you can enter a model name manually.": "Get models list failed, you can enter a model name manually.",
|
||||||
"Default greeting message": "Hi, I am {{ nickname }}"
|
"Default greeting message": "Hi, I am {{ nickname }}",
|
||||||
|
"Chat error warning": "Failed to send message. Please contact the administrator or try again later."
|
||||||
}
|
}
|
||||||
|
@ -32,5 +32,9 @@ export default defineCollection({
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
defaultValue: 'text',
|
defaultValue: 'text',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -79,6 +79,7 @@ export default {
|
|||||||
messageId: row.messageId,
|
messageId: row.messageId,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
content: {
|
content: {
|
||||||
|
title: row.title,
|
||||||
content: row.content,
|
content: row.content,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
},
|
},
|
||||||
@ -86,19 +87,30 @@ export default {
|
|||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async sendMessages(ctx: Context, next: Next) {
|
async sendMessages(ctx: Context, next: Next) {
|
||||||
|
ctx.set({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
const { sessionId, aiEmployee, messages } = ctx.action.params.values || {};
|
const { sessionId, aiEmployee, messages } = ctx.action.params.values || {};
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
ctx.throw(400);
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'sessionId is required' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const userMessage = messages.find((message: any) => message.role === 'user');
|
const userMessage = messages.find((message: any) => message.role === 'user');
|
||||||
if (!userMessage) {
|
if (!userMessage) {
|
||||||
ctx.throw(400);
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'user message is required' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const conversation = await ctx.db.getRepository('aiConversations').findOne({
|
const conversation = await ctx.db.getRepository('aiConversations').findOne({
|
||||||
filterByTk: sessionId,
|
filterByTk: sessionId,
|
||||||
});
|
});
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
ctx.throw(400);
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'conversation not found' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
||||||
filter: {
|
filter: {
|
||||||
@ -106,11 +118,16 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!employee) {
|
if (!employee) {
|
||||||
ctx.throw(400);
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'AI employee not found' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const modelSettings = employee.modelSettings;
|
const modelSettings = employee.modelSettings;
|
||||||
if (!modelSettings?.llmService) {
|
if (!modelSettings?.llmService) {
|
||||||
ctx.throw(500);
|
ctx.log.error('llmService not configured');
|
||||||
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const service = await ctx.db.getRepository('llmServices').findOne({
|
const service = await ctx.db.getRepository('llmServices').findOne({
|
||||||
filter: {
|
filter: {
|
||||||
@ -118,12 +135,18 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!service) {
|
if (!service) {
|
||||||
ctx.throw(500);
|
ctx.log.error('llmService not found');
|
||||||
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
|
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
|
||||||
const providerOptions = plugin.aiManager.llmProviders.get(service.provider);
|
const providerOptions = plugin.aiManager.llmProviders.get(service.provider);
|
||||||
if (!providerOptions) {
|
if (!providerOptions) {
|
||||||
ctx.throw(500);
|
ctx.log.error('llmService provider not found');
|
||||||
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -133,17 +156,15 @@ export default {
|
|||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content.content,
|
content: message.content.content,
|
||||||
type: message.content.type,
|
type: message.content.type,
|
||||||
|
title: message.content.title,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.error(err);
|
ctx.log.error(err);
|
||||||
ctx.throw(500);
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
ctx.set({
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
});
|
|
||||||
ctx.status = 200;
|
ctx.status = 200;
|
||||||
const userMessages = [];
|
const userMessages = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
@ -177,7 +198,15 @@ export default {
|
|||||||
messages: msgs,
|
messages: msgs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const stream = await provider.stream();
|
let stream: any;
|
||||||
|
try {
|
||||||
|
stream = await provider.stream();
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.error(err);
|
||||||
|
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
|
||||||
|
ctx.res.end();
|
||||||
|
return next();
|
||||||
|
}
|
||||||
let message = '';
|
let message = '';
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
if (!chunk.content) {
|
if (!chunk.content) {
|
||||||
@ -195,7 +224,7 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.res.end();
|
ctx.res.end();
|
||||||
// await next();
|
await next();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user