chore: optimize code

This commit is contained in:
xilesun 2025-04-04 21:33:32 +08:00
parent c9ed2208ec
commit e79359c683
19 changed files with 1018 additions and 506 deletions

View File

@ -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);
};

View File

@ -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[];

View File

@ -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;

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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>
); );
}; };

View File

@ -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,
};
};

View File

@ -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>
); );
}; });

View File

@ -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');

View File

@ -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,
};
},
}));
},
},
],
});

View File

@ -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>

View File

@ -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 employees 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,
}, },
}); });
}} }}

View File

@ -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;
}[];
};

View File

@ -0,0 +1,47 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React 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);
}
},
},
},
};
};

View File

@ -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);

View File

@ -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 });
} }

View File

@ -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."
} }

View File

@ -32,5 +32,9 @@ export default defineCollection({
type: 'string', type: 'string',
defaultValue: 'text', defaultValue: 'text',
}, },
{
name: 'title',
type: 'string',
},
], ],
}); });

View File

@ -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();
}, },
}, },
}; };