mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +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 { ChatBoxProvider } from './chatbox/ChatBoxProvider';
|
||||
import { useAPIClient, useRequest } from '@nocobase/client';
|
||||
|
||||
export type AIEmployee = {
|
||||
username: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
greeting?: string;
|
||||
};
|
||||
import { AIEmployee } from './types';
|
||||
|
||||
export const AIEmployeesContext = createContext<{
|
||||
aiEmployees: AIEmployee[];
|
||||
|
@ -10,25 +10,20 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import { BuildOutlined } from '@ant-design/icons';
|
||||
|
||||
export type AttachmentType = 'image' | 'uiSchema';
|
||||
export type AttachmentProps = {
|
||||
type: AttachmentType;
|
||||
content: string;
|
||||
};
|
||||
import { AttachmentProps } from '../types';
|
||||
|
||||
export const Attachment: React.FC<
|
||||
AttachmentProps & {
|
||||
closeable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
> = ({ type, content, closeable, onClose }) => {
|
||||
> = ({ type, title, content, closeable, onClose }) => {
|
||||
let prefix: React.ReactNode;
|
||||
switch (type) {
|
||||
case 'uiSchema':
|
||||
prefix = (
|
||||
<>
|
||||
<BuildOutlined /> UI Schema {'>'}{' '}
|
||||
<BuildOutlined /> {title} {'>'}{' '}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
@ -7,29 +7,24 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, Modal, Tag } from 'antd';
|
||||
import { Conversations, Sender, Attachments, Bubble } from '@ant-design/x';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, App } from 'antd';
|
||||
import { Conversations, Sender, Bubble } from '@ant-design/x';
|
||||
import type { ConversationsProps } from '@ant-design/x';
|
||||
import {
|
||||
CloseOutlined,
|
||||
ExpandOutlined,
|
||||
EditOutlined,
|
||||
LayoutOutlined,
|
||||
DeleteOutlined,
|
||||
BuildOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAPIClient, useRequest, useToken } from '@nocobase/client';
|
||||
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useAPIClient, useToken } from '@nocobase/client';
|
||||
import { useT } from '../../locale';
|
||||
import { ChatBoxContext } from './ChatBoxProvider';
|
||||
const { Header, Footer, Sider, Content } = Layout;
|
||||
import { avatars } from '../avatars';
|
||||
import { AIEmployee, AIEmployeesContext, useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
||||
import { Attachment } from './Attachment';
|
||||
import { ChatBoxContext } from './ChatBoxContext';
|
||||
import { AIEmployee } from '../types';
|
||||
|
||||
export const ChatBox: React.FC = () => {
|
||||
const { modal, message } = App.useApp();
|
||||
const api = useAPIClient();
|
||||
const {
|
||||
send,
|
||||
@ -46,6 +41,9 @@ export const ChatBox: React.FC = () => {
|
||||
setAttachments,
|
||||
responseLoading,
|
||||
senderRef,
|
||||
senderValue,
|
||||
setSenderValue,
|
||||
clear,
|
||||
} = useContext(ChatBoxContext);
|
||||
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
||||
const {
|
||||
@ -54,7 +52,7 @@ export const ChatBox: React.FC = () => {
|
||||
} = useAIEmployeesContext();
|
||||
const t = useT();
|
||||
const { token } = useToken();
|
||||
const [showConversations, setShowConversations] = useState(true);
|
||||
const [showConversations, setShowConversations] = useState(false);
|
||||
const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])];
|
||||
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
||||
key: conversation.sessionId,
|
||||
@ -66,9 +64,9 @@ export const ChatBox: React.FC = () => {
|
||||
await api.resource('aiConversations').destroy({
|
||||
filterByTk: sessionId,
|
||||
});
|
||||
message.success(t('Deleted successfully'));
|
||||
conversationsService.refresh();
|
||||
setCurrentConversation(undefined);
|
||||
setMessages([]);
|
||||
clear();
|
||||
};
|
||||
|
||||
const getMessages = async (sessionId: string) => {
|
||||
@ -132,7 +130,7 @@ export const ChatBox: React.FC = () => {
|
||||
</Button>
|
||||
) : (
|
||||
<Popover
|
||||
placement="bottomLeft"
|
||||
placement="rightTop"
|
||||
content={
|
||||
<div
|
||||
style={{
|
||||
@ -185,7 +183,10 @@ export const ChatBox: React.FC = () => {
|
||||
padding: 0;
|
||||
${highlight}
|
||||
`}
|
||||
onClick={() => setFilterEmployee(aiEmployee.username)}
|
||||
onClick={() => {
|
||||
clear();
|
||||
setFilterEmployee(aiEmployee.username);
|
||||
}}
|
||||
>
|
||||
<Avatar src={avatars(aiEmployee.avatar)} shape="square" size={40} />
|
||||
</Button>
|
||||
@ -237,7 +238,7 @@ export const ChatBox: React.FC = () => {
|
||||
onClick: ({ key }) => {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: t('Delete this conversation?'),
|
||||
content: t('Are you sure to delete this conversation?'),
|
||||
onOk: () => deleteConversation(conversation.key),
|
||||
@ -279,11 +280,7 @@ export const ChatBox: React.FC = () => {
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setCurrentConversation(undefined);
|
||||
setMessages([]);
|
||||
senderRef.current?.focus({
|
||||
cursor: 'start',
|
||||
});
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@ -301,6 +298,7 @@ export const ChatBox: React.FC = () => {
|
||||
style={{
|
||||
margin: '16px 0',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{messages?.length ? (
|
||||
@ -314,10 +312,11 @@ export const ChatBox: React.FC = () => {
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '64px',
|
||||
margin: '0 auto',
|
||||
marginTop: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<EmptyIcon />
|
||||
@ -331,7 +330,11 @@ export const ChatBox: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Sender
|
||||
value={senderValue}
|
||||
ref={senderRef}
|
||||
onChange={(value) => {
|
||||
setSenderValue(value);
|
||||
}}
|
||||
onSubmit={(content) =>
|
||||
send({
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
|
||||
import { FloatButton, Avatar, Typography, GetProp, GetRef, Button } from 'antd';
|
||||
import type { BubbleProps, Sender } from '@ant-design/x';
|
||||
import { Bubble } from '@ant-design/x';
|
||||
import { CurrentUserContext, useAPIClient, useLocalVariables, useRequest, useVariables } from '@nocobase/client';
|
||||
import React, { useContext } from 'react';
|
||||
import { FloatButton, Avatar } from 'antd';
|
||||
import { CurrentUserContext } from '@nocobase/client';
|
||||
import { ChatBox } from './ChatBox';
|
||||
import icon from '../icon.svg';
|
||||
import { css } from '@emotion/css';
|
||||
import { AIEmployee, AIEmployeesContext } from '../AIEmployeesProvider';
|
||||
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);
|
||||
import { ChatBoxContext, useChatBoxContext } from './ChatBoxContext';
|
||||
|
||||
export const ChatBoxProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = (props) => {
|
||||
const t = useT();
|
||||
const api = useAPIClient();
|
||||
const ctx = useContext(CurrentUserContext);
|
||||
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([]);
|
||||
}
|
||||
};
|
||||
const currentUserCtx = useContext(CurrentUserContext);
|
||||
const chatBoxCtx = useChatBoxContext();
|
||||
const { open, setOpen } = chatBoxCtx;
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiEmployees) {
|
||||
return;
|
||||
}
|
||||
const roles = aiEmployees.reduce((prev, aiEmployee) => {
|
||||
return {
|
||||
...prev,
|
||||
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
||||
};
|
||||
}, {});
|
||||
setRoles((prev) => ({
|
||||
...prev,
|
||||
...roles,
|
||||
}));
|
||||
}, [aiEmployees]);
|
||||
|
||||
if (!ctx?.data?.data) {
|
||||
if (!currentUserCtx?.data?.data) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
return (
|
||||
<ChatBoxContext.Provider
|
||||
value={{
|
||||
open: openChatBox,
|
||||
setOpen: setOpenChatBox,
|
||||
filterEmployee,
|
||||
setFilterEmployee,
|
||||
conversations,
|
||||
currentConversation,
|
||||
setCurrentConversation,
|
||||
messages,
|
||||
setMessages,
|
||||
roles,
|
||||
responseLoading,
|
||||
attachments,
|
||||
setAttachments,
|
||||
actions,
|
||||
setActions,
|
||||
senderRef,
|
||||
send,
|
||||
}}
|
||||
>
|
||||
<ChatBoxContext.Provider value={chatBoxCtx}>
|
||||
{props.children}
|
||||
{!openChatBox && (
|
||||
{!open && (
|
||||
<div
|
||||
className={css`
|
||||
.ant-float-btn {
|
||||
@ -340,13 +53,13 @@ export const ChatBoxProvider: React.FC<{
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
setOpenChatBox(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
shape="square"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{openChatBox ? <ChatBox /> : null}
|
||||
{open ? <ChatBox /> : null}
|
||||
</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 { Avatar, Tag, Popover, Divider, Button } from 'antd';
|
||||
import { avatars } from '../avatars';
|
||||
import { ChatBoxContext } from '../chatbox/ChatBoxProvider';
|
||||
import { AIEmployee } from '../AIEmployeesProvider';
|
||||
import {
|
||||
SortableItem,
|
||||
useBlockContext,
|
||||
@ -19,26 +17,64 @@ import {
|
||||
useSchemaToolbarRender,
|
||||
useToken,
|
||||
useVariables,
|
||||
withDynamicSchemaProps,
|
||||
} from '@nocobase/client';
|
||||
import { useFieldSchema } from '@formily/react';
|
||||
import { useT } from '../../locale';
|
||||
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<{
|
||||
aiEmployee: AIEmployee;
|
||||
extraInfo?: string;
|
||||
}> = ({ aiEmployee, extraInfo }) => {
|
||||
taskDesc?: string;
|
||||
attachments: string[];
|
||||
actions: string[];
|
||||
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, attachments: selectedAttachments, actions: selectedActions }) => {
|
||||
const t = useT();
|
||||
const { setOpen, send, setAttachments, setFilterEmployee, setCurrentConversation, setActions } =
|
||||
useContext(ChatBoxContext);
|
||||
const { setOpen, send, setAttachments, setFilterEmployee, setActions, clear } = useContext(ChatBoxContext);
|
||||
const { token } = useToken();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const { render } = useSchemaToolbarRender(fieldSchema);
|
||||
const variables = useVariables();
|
||||
const localVariables = useLocalVariables();
|
||||
const { name: blockType } = useBlockContext() || {};
|
||||
const form = useForm();
|
||||
const { attachments, actions } = useAIEmployeeChatContext();
|
||||
|
||||
return (
|
||||
<SortableItem
|
||||
@ -46,43 +82,42 @@ export const AIEmployeeButton: React.FC<{
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={async () => {
|
||||
clear();
|
||||
setOpen(true);
|
||||
setCurrentConversation(undefined);
|
||||
setFilterEmployee(aiEmployee.username);
|
||||
setAttachments([]);
|
||||
setActions([]);
|
||||
const messages = [];
|
||||
if (blockType === 'form') {
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
if (selectedAttachments && selectedAttachments.length) {
|
||||
setAttachments((prev) => {
|
||||
const newAttachments = selectedAttachments.map((name: string) => {
|
||||
const attachment = attachments[name];
|
||||
return {
|
||||
type: attachment.type,
|
||||
title: attachment.title,
|
||||
content: attachment.content,
|
||||
};
|
||||
});
|
||||
return [...prev, ...newAttachments];
|
||||
});
|
||||
}
|
||||
let message = fieldSchema['x-component-props']?.message;
|
||||
if (selectedActions && selectedActions.length) {
|
||||
setActions((prev) => {
|
||||
const newActions = selectedActions.map((name: string) => {
|
||||
const action = actions[name];
|
||||
return {
|
||||
icon: action.icon,
|
||||
content: action.title,
|
||||
onClick: action.action,
|
||||
};
|
||||
});
|
||||
return [...prev, ...newActions];
|
||||
});
|
||||
}
|
||||
const messages = [];
|
||||
const message = fieldSchema['x-component-props']?.message;
|
||||
if (message) {
|
||||
message = await variables
|
||||
?.parseVariable(fieldSchema['x-component-props']?.message, localVariables)
|
||||
.then(({ value }) => value);
|
||||
const content = await replaceVariables(message.content, variables, localVariables);
|
||||
messages.push({
|
||||
type: 'text',
|
||||
content: message,
|
||||
type: message.type || 'text',
|
||||
content,
|
||||
});
|
||||
}
|
||||
send({
|
||||
@ -134,7 +169,7 @@ export const AIEmployeeButton: React.FC<{
|
||||
{t('Bio')}
|
||||
</Divider>
|
||||
<p>{aiEmployee.bio}</p>
|
||||
{extraInfo && (
|
||||
{taskDesc && (
|
||||
<>
|
||||
<Divider
|
||||
orientation="left"
|
||||
@ -143,9 +178,9 @@ export const AIEmployeeButton: React.FC<{
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{t('Extra information')}
|
||||
{t('Task description')}
|
||||
</Divider>
|
||||
<p>{extraInfo}</p>
|
||||
<p>{taskDesc}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -164,4 +199,4 @@ export const AIEmployeeButton: React.FC<{
|
||||
{render()}
|
||||
</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 { 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 {
|
||||
CollectionRecordProvider,
|
||||
SchemaComponent,
|
||||
@ -311,6 +311,7 @@ const useEditActionProps = () => {
|
||||
|
||||
export const Employees: React.FC = () => {
|
||||
const t = useT();
|
||||
const { message, modal } = App.useApp();
|
||||
const { token } = useToken();
|
||||
const api = useAPIClient();
|
||||
const { data, loading, refresh } = useRequest<
|
||||
@ -327,6 +328,20 @@ export const Employees: React.FC = () => {
|
||||
.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
|
||||
@ -456,13 +471,21 @@ export const Employees: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
<DeleteOutlined key="delete" />,
|
||||
<DeleteOutlined key="delete" onClick={() => del(employee.username)} />,
|
||||
]}
|
||||
>
|
||||
<Meta
|
||||
avatar={employee.avatar ? <Avatar src={avatars(employee.avatar)} /> : null}
|
||||
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>
|
||||
</Col>
|
||||
|
@ -19,9 +19,10 @@ import {
|
||||
} from '@nocobase/client';
|
||||
import { useT } from '../../locale';
|
||||
import { avatars } from '../avatars';
|
||||
import { Card, Avatar } from 'antd';
|
||||
import { Card, Avatar, Tooltip } from 'antd';
|
||||
const { Meta } = Card;
|
||||
import { Schema } from '@formily/react';
|
||||
import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider';
|
||||
|
||||
export const useAIEmployeeButtonVariableOptions = () => {
|
||||
const collection = useCollection();
|
||||
@ -57,6 +58,31 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
|
||||
const t = useT();
|
||||
const { dn } = useSchemaSettings();
|
||||
const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {};
|
||||
const { attachments = [], actions = [] } = useAIEmployeeChatContext();
|
||||
const attachmentsOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(attachments).map(([name, item]) => ({
|
||||
label: (
|
||||
<Tooltip title={item.description} placement="right">
|
||||
{item.title}
|
||||
</Tooltip>
|
||||
),
|
||||
value: name,
|
||||
})),
|
||||
[attachments],
|
||||
);
|
||||
const actionsOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(actions).map(([name, item]) => ({
|
||||
label: (
|
||||
<Tooltip title={item.description} placement="right">
|
||||
{item.title}
|
||||
</Tooltip>
|
||||
),
|
||||
value: name,
|
||||
})),
|
||||
[actions],
|
||||
);
|
||||
return (
|
||||
<SchemaSettingsModalItem
|
||||
scope={{ useAIEmployeeButtonVariableOptions }}
|
||||
@ -68,46 +94,130 @@ export const aiEmployeeButtonSettings = new SchemaSettings({
|
||||
type: 'void',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': () => (
|
||||
<Card variant="borderless">
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
maxWidth: 520,
|
||||
}}
|
||||
>
|
||||
<Meta
|
||||
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} /> : null}
|
||||
avatar={aiEmployee.avatar ? <Avatar src={avatars(aiEmployee.avatar)} size={48} /> : null}
|
||||
title={aiEmployee.nickname}
|
||||
description={aiEmployee.bio}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
message: {
|
||||
taskDesc: {
|
||||
type: 'string',
|
||||
title: t('Message'),
|
||||
title: t('Task description'),
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: '{{ useAIEmployeeButtonVariableOptions }}',
|
||||
fieldNames: {
|
||||
value: 'name',
|
||||
label: 'title',
|
||||
'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',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Variable.RawTextArea',
|
||||
'x-component-props': {
|
||||
scope: '{{ useAIEmployeeButtonVariableOptions }}',
|
||||
changeOnSelect: true,
|
||||
fieldNames: {
|
||||
value: 'name',
|
||||
label: 'title',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
default: dn.getSchemaAttribute('x-component-props.message'),
|
||||
'x-reactions': {
|
||||
dependencies: ['.manualMessage'],
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{ !$deps[0] }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
extraInfo: {
|
||||
type: 'string',
|
||||
title: t('Extra Information'),
|
||||
attachments: {
|
||||
type: 'array',
|
||||
title: t('Attachments'),
|
||||
'x-component': 'Checkbox.Group',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.TextArea',
|
||||
default: dn.getSchemaAttribute('x-component-props.extraInfo'),
|
||||
enum: attachmentsOptions,
|
||||
default: dn.getSchemaAttribute('x-component-props.attachments'),
|
||||
'x-reactions': {
|
||||
target: 'attachments',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{$self.value.length}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
type: 'array',
|
||||
title: t('Actions'),
|
||||
'x-component': 'Checkbox.Group',
|
||||
'x-decorator': 'FormItem',
|
||||
enum: actionsOptions,
|
||||
default: dn.getSchemaAttribute('x-component-props.actions'),
|
||||
'x-reactions': {
|
||||
target: 'actions',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{$self.value.length}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={t('Edit')}
|
||||
onSubmit={({ message, extraInfo }) => {
|
||||
onSubmit={({ message, taskDesc, manualMessage, attachments, actions }) => {
|
||||
dn.deepMerge({
|
||||
'x-uid': dn.getSchemaAttribute('x-uid'),
|
||||
'x-component-props': {
|
||||
aiEmployee,
|
||||
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 { tval } from '@nocobase/utils/client';
|
||||
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 { useDetailsAIEmployeeChatContext, useFormAIEmployeeChatContext } from './ai-employees/useBlockChatContext';
|
||||
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 { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices');
|
||||
const { MessagesSettings } = lazy(() => import('./chat-settings/Messages'), 'MessagesSettings');
|
||||
@ -39,6 +44,11 @@ export class PluginAIClient extends Plugin {
|
||||
this.app.use(AIEmployeesProvider);
|
||||
this.app.addComponents({
|
||||
AIEmployeeButton,
|
||||
AIEmployeeChatProvider,
|
||||
});
|
||||
this.app.addScopes({
|
||||
useDetailsAIEmployeeChatContext,
|
||||
useFormAIEmployeeChatContext,
|
||||
});
|
||||
this.app.pluginSettingsManager.add('ai', {
|
||||
icon: 'TeamOutlined',
|
||||
@ -58,7 +68,16 @@ export class PluginAIClient extends Plugin {
|
||||
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.aiManager.registerLLMProvider('openai', openaiProviderOptions);
|
||||
|
@ -13,7 +13,7 @@ import { useApp } from '@nocobase/client';
|
||||
|
||||
export const namespace = pkg.name;
|
||||
|
||||
export function useT() {
|
||||
export function useT(): any {
|
||||
const app = useApp();
|
||||
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.",
|
||||
"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.",
|
||||
"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',
|
||||
defaultValue: 'text',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -79,6 +79,7 @@ export default {
|
||||
messageId: row.messageId,
|
||||
role: row.role,
|
||||
content: {
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
},
|
||||
@ -86,19 +87,30 @@ export default {
|
||||
await 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 || {};
|
||||
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');
|
||||
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({
|
||||
filterByTk: sessionId,
|
||||
});
|
||||
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({
|
||||
filter: {
|
||||
@ -106,11 +118,16 @@ export default {
|
||||
},
|
||||
});
|
||||
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;
|
||||
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({
|
||||
filter: {
|
||||
@ -118,12 +135,18 @@ export default {
|
||||
},
|
||||
});
|
||||
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 providerOptions = plugin.aiManager.llmProviders.get(service.provider);
|
||||
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 {
|
||||
@ -133,17 +156,15 @@ export default {
|
||||
role: message.role,
|
||||
content: message.content.content,
|
||||
type: message.content.type,
|
||||
title: message.content.title,
|
||||
})),
|
||||
});
|
||||
} catch (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;
|
||||
const userMessages = [];
|
||||
for (const msg of messages) {
|
||||
@ -177,7 +198,15 @@ export default {
|
||||
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 = '';
|
||||
for await (const chunk of stream) {
|
||||
if (!chunk.content) {
|
||||
@ -195,7 +224,7 @@ export default {
|
||||
},
|
||||
});
|
||||
ctx.res.end();
|
||||
// await next();
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user