mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +08:00
chore: update
This commit is contained in:
parent
f60cd60c33
commit
421b2827d6
@ -13,6 +13,8 @@ import { ChatBoxProvider } from './chatbox/ChatBoxProvider';
|
|||||||
import { useAPIClient, useRequest } from '@nocobase/client';
|
import { useAPIClient, useRequest } from '@nocobase/client';
|
||||||
import { AIEmployee } from './types';
|
import { AIEmployee } from './types';
|
||||||
import { AISelectionProvider } from './selector/AISelectorProvider';
|
import { AISelectionProvider } from './selector/AISelectorProvider';
|
||||||
|
import { ChatMessagesProvider } from './chatbox/ChatMessagesProvider';
|
||||||
|
import { ChatConversationsProvider } from './chatbox/ChatConversationsProvider';
|
||||||
|
|
||||||
export const AIEmployeesContext = createContext<{
|
export const AIEmployeesContext = createContext<{
|
||||||
aiEmployees: AIEmployee[];
|
aiEmployees: AIEmployee[];
|
||||||
@ -27,7 +29,11 @@ export const AIEmployeesProvider: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<AISelectionProvider>
|
<AISelectionProvider>
|
||||||
<AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}>
|
<AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}>
|
||||||
<ChatBoxProvider>{props.children}</ChatBoxProvider>
|
<ChatConversationsProvider>
|
||||||
|
<ChatMessagesProvider>
|
||||||
|
<ChatBoxProvider>{props.children}</ChatBoxProvider>
|
||||||
|
</ChatMessagesProvider>
|
||||||
|
</ChatConversationsProvider>
|
||||||
</AIEmployeesContext.Provider>
|
</AIEmployeesContext.Provider>
|
||||||
</AISelectionProvider>
|
</AISelectionProvider>
|
||||||
);
|
);
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { List, Popover, Button, Avatar, Divider } from 'antd';
|
import { List, Popover, Button, Avatar, Divider } from 'antd';
|
||||||
import { useToken } from '@nocobase/client';
|
import { useToken } from '@nocobase/client';
|
||||||
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||||
@ -17,13 +17,48 @@ import { avatars } from '../avatars';
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { Sender } from '@ant-design/x';
|
import { Sender } from '@ant-design/x';
|
||||||
import { ProfileCard } from '../ProfileCard';
|
import { ProfileCard } from '../ProfileCard';
|
||||||
|
import { AIEmployee } from '../types';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { useChatMessages } from './ChatMessagesProvider';
|
||||||
|
import { useChatConversations } from './ChatConversationsProvider';
|
||||||
|
|
||||||
export const AIEmployeeHeader: React.FC = () => {
|
export const AIEmployeeHeader: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
service: { loading },
|
service: { loading },
|
||||||
aiEmployees,
|
aiEmployees,
|
||||||
} = useAIEmployeesContext();
|
} = useAIEmployeesContext();
|
||||||
const switchAIEmployee = useChatBoxContext('switchAIEmployee');
|
const t = useT();
|
||||||
|
const { setMessages, addMessage } = useChatMessages();
|
||||||
|
const { currentConversation } = useChatConversations();
|
||||||
|
const setCurrentEmployee = useChatBoxContext('setCurrentEmployee');
|
||||||
|
const setSenderPlaceholder = useChatBoxContext('setSenderPlaceholder');
|
||||||
|
const setSenderValue = useChatBoxContext('setSenderValue');
|
||||||
|
const senderRef = useChatBoxContext('senderRef');
|
||||||
|
const infoForm = useChatBoxContext('infoForm');
|
||||||
|
const switchAIEmployee = useCallback(
|
||||||
|
(aiEmployee: AIEmployee) => {
|
||||||
|
const greetingMsg = {
|
||||||
|
key: uid(),
|
||||||
|
role: aiEmployee.username,
|
||||||
|
content: {
|
||||||
|
type: 'greeting' as const,
|
||||||
|
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setCurrentEmployee(aiEmployee);
|
||||||
|
setSenderPlaceholder(aiEmployee.chatSettings?.senderPlaceholder);
|
||||||
|
infoForm.reset();
|
||||||
|
senderRef.current?.focus();
|
||||||
|
if (!currentConversation) {
|
||||||
|
setMessages([greetingMsg]);
|
||||||
|
} else {
|
||||||
|
addMessage(greetingMsg);
|
||||||
|
setSenderValue('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentConversation, infoForm],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sender.Header closable={false}>
|
<Sender.Header closable={false}>
|
||||||
<List
|
<List
|
||||||
|
@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Layout, Card, Button } from 'antd';
|
import { Layout, Card, Button } from 'antd';
|
||||||
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined } from '@ant-design/icons';
|
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, ShrinkOutlined } from '@ant-design/icons';
|
||||||
import { useToken } from '@nocobase/client';
|
import { useToken } from '@nocobase/client';
|
||||||
const { Header, Footer, Sider, Content } = Layout;
|
const { Header, Footer, Sider } = Layout;
|
||||||
import { useChatBoxContext } from './ChatBoxContext';
|
import { useChatBoxContext } from './ChatBoxContext';
|
||||||
import { Conversations } from './Conversations';
|
import { Conversations } from './Conversations';
|
||||||
import { Messages } from './Messages';
|
import { Messages } from './Messages';
|
||||||
@ -24,25 +24,37 @@ export const ChatBox: React.FC = () => {
|
|||||||
const currentEmployee = useChatBoxContext('currentEmployee');
|
const currentEmployee = useChatBoxContext('currentEmployee');
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const [showConversations, setShowConversations] = useState(false);
|
const [showConversations, setShowConversations] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const { selectable } = useAISelectionContext();
|
const { selectable } = useAISelectionContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={
|
||||||
position: 'fixed',
|
!expanded
|
||||||
right: '16px',
|
? {
|
||||||
bottom: '16px',
|
position: 'fixed',
|
||||||
width: '90%',
|
right: '16px',
|
||||||
maxWidth: '760px',
|
bottom: '16px',
|
||||||
height: '90%',
|
width: '90%',
|
||||||
maxHeight: '560px',
|
maxWidth: '760px',
|
||||||
zIndex: selectable ? -1 : 1000,
|
height: '90%',
|
||||||
}}
|
maxHeight: '560px',
|
||||||
|
zIndex: selectable ? -1 : 1000,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: 'fixed',
|
||||||
|
right: '16px',
|
||||||
|
bottom: '16px',
|
||||||
|
width: '95%',
|
||||||
|
height: '95%',
|
||||||
|
zIndex: selectable ? -1 : 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Card style={{ height: '100%' }} styles={{ body: { height: '100%', paddingTop: 0 } }}>
|
<Card style={{ height: '100%' }} styles={{ body: { height: '100%', paddingTop: 0 } }}>
|
||||||
<Layout style={{ height: '100%' }}>
|
<Layout style={{ height: '100%' }}>
|
||||||
<Sider
|
<Sider
|
||||||
width="30%"
|
width={!expanded ? '30%' : '15%'}
|
||||||
style={{
|
style={{
|
||||||
display: showConversations ? 'block' : 'none',
|
display: showConversations ? 'block' : 'none',
|
||||||
backgroundColor: token.colorBgContainer,
|
backgroundColor: token.colorBgContainer,
|
||||||
@ -78,19 +90,15 @@ export const ChatBox: React.FC = () => {
|
|||||||
float: 'right',
|
float: 'right',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button icon={<ExpandOutlined />} type="text" />
|
<Button
|
||||||
|
icon={!expanded ? <ExpandOutlined /> : <ShrinkOutlined />}
|
||||||
|
type="text"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
/>
|
||||||
<Button icon={<CloseOutlined />} type="text" onClick={() => setOpen(false)} />
|
<Button icon={<CloseOutlined />} type="text" onClick={() => setOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Messages />
|
||||||
style={{
|
|
||||||
margin: '16px 0',
|
|
||||||
overflow: 'auto',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Messages />
|
|
||||||
</Content>
|
|
||||||
<Footer
|
<Footer
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: token.colorBgContainer,
|
backgroundColor: token.colorBgContainer,
|
||||||
|
@ -7,60 +7,43 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Conversation, Message, SendOptions, AIEmployee, ShortcutOptions } from '../types';
|
||||||
AttachmentProps,
|
import { Avatar, GetProp, GetRef, Button, Alert, Popover } from 'antd';
|
||||||
Conversation,
|
|
||||||
Message,
|
|
||||||
Action,
|
|
||||||
SendOptions,
|
|
||||||
AIEmployee,
|
|
||||||
MessageType,
|
|
||||||
ShortcutOptions,
|
|
||||||
} from '../types';
|
|
||||||
import { Avatar, GetProp, GetRef, Button, Alert, Space, Popover } from 'antd';
|
|
||||||
import type { Sender } from '@ant-design/x';
|
import type { Sender } from '@ant-design/x';
|
||||||
import React, { useContext, useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
import React, { useContext, useEffect, useState, useRef, useMemo, useCallback, createRef } from 'react';
|
||||||
import { Bubble } from '@ant-design/x';
|
import { Bubble } from '@ant-design/x';
|
||||||
import { useAPIClient, useRequest } from '@nocobase/client';
|
import { useAPIClient } from '@nocobase/client';
|
||||||
import { AIEmployeesContext } from '../AIEmployeesProvider';
|
import { AIEmployeesContext } from '../AIEmployeesProvider';
|
||||||
import { Attachment } from './Attachment';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
|
||||||
import { avatars } from '../avatars';
|
import { avatars } from '../avatars';
|
||||||
import { useChatMessages } from './useChatMessages';
|
|
||||||
import { uid } from '@formily/shared';
|
import { uid } from '@formily/shared';
|
||||||
import { useT } from '../../locale';
|
import { useT } from '../../locale';
|
||||||
import { createForm, Form } from '@formily/core';
|
import { createForm, Form } from '@formily/core';
|
||||||
import { ProfileCard } from '../ProfileCard';
|
import { ProfileCard } from '../ProfileCard';
|
||||||
import { InfoFormMessage } from './InfoForm';
|
|
||||||
import { createContext, useContextSelector } from 'use-context-selector';
|
import { createContext, useContextSelector } from 'use-context-selector';
|
||||||
|
import { AIMessage, UserMessage } from './MessageRenderer';
|
||||||
|
import { useChatMessages } from './ChatMessagesProvider';
|
||||||
|
import { useChatConversations } from './ChatConversationsProvider';
|
||||||
|
|
||||||
export const ChatBoxContext = createContext<{
|
type ChatBoxContextValues = {
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
currentEmployee: AIEmployee;
|
currentEmployee: AIEmployee;
|
||||||
setCurrentEmployee: React.Dispatch<React.SetStateAction<AIEmployee>>;
|
setCurrentEmployee: React.Dispatch<React.SetStateAction<AIEmployee>>;
|
||||||
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 };
|
roles: { [role: string]: any };
|
||||||
responseLoading: boolean;
|
|
||||||
senderValue: string;
|
senderValue: string;
|
||||||
setSenderValue: React.Dispatch<React.SetStateAction<string>>;
|
setSenderValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
|
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
|
||||||
senderPlaceholder: string;
|
senderPlaceholder: string;
|
||||||
|
setSenderPlaceholder: React.Dispatch<React.SetStateAction<string>>;
|
||||||
infoForm: Form;
|
infoForm: Form;
|
||||||
showInfoForm: boolean;
|
showInfoForm: boolean;
|
||||||
switchAIEmployee: (aiEmployee: AIEmployee) => void;
|
|
||||||
startNewConversation: () => void;
|
startNewConversation: () => void;
|
||||||
triggerShortcut: (options: ShortcutOptions) => void;
|
triggerShortcut: (options: ShortcutOptions) => void;
|
||||||
send(opts: SendOptions): void;
|
send(opts: SendOptions): void;
|
||||||
}>({} as any);
|
};
|
||||||
|
|
||||||
|
export const ChatBoxContext = createContext<ChatBoxContextValues>({} as any);
|
||||||
|
|
||||||
const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
||||||
user: {
|
user: {
|
||||||
@ -68,16 +51,12 @@ const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
|||||||
styles: {
|
styles: {
|
||||||
content: {
|
content: {
|
||||||
maxWidth: '400px',
|
maxWidth: '400px',
|
||||||
|
margin: '8px 8px 8px 0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variant: 'borderless',
|
variant: 'borderless',
|
||||||
messageRender: (msg: any) => {
|
messageRender: (msg: any) => {
|
||||||
switch (msg.type) {
|
return <UserMessage msg={msg} />;
|
||||||
case 'text':
|
|
||||||
return <Bubble content={msg.content} />;
|
|
||||||
default:
|
|
||||||
return <Attachment {...msg} />;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
@ -97,24 +76,6 @@ const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
info: {
|
|
||||||
placement: 'start',
|
|
||||||
avatar: { icon: '', style: { visibility: 'hidden' } },
|
|
||||||
typing: { step: 5, interval: 20 },
|
|
||||||
style: {
|
|
||||||
maxWidth: 400,
|
|
||||||
marginInlineEnd: 48,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
footer: {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// variant: 'borderless',
|
|
||||||
messageRender: (msg: any) => {
|
|
||||||
return <InfoFormMessage values={msg.content} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
action: {
|
action: {
|
||||||
placement: 'start',
|
placement: 'start',
|
||||||
avatar: { icon: '', style: { visibility: 'hidden' } },
|
avatar: { icon: '', style: { visibility: 'hidden' } },
|
||||||
@ -148,8 +109,9 @@ const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
|||||||
) : null,
|
) : null,
|
||||||
typing: { step: 5, interval: 20 },
|
typing: { step: 5, interval: 20 },
|
||||||
style: {
|
style: {
|
||||||
maxWidth: 400,
|
maxWidth: '80%',
|
||||||
marginInlineEnd: 48,
|
marginInlineEnd: 48,
|
||||||
|
margin: '8px 0',
|
||||||
},
|
},
|
||||||
styles: {
|
styles: {
|
||||||
footer: {
|
footer: {
|
||||||
@ -158,33 +120,17 @@ const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
|||||||
},
|
},
|
||||||
variant: 'borderless',
|
variant: 'borderless',
|
||||||
messageRender: (msg: any) => {
|
messageRender: (msg: any) => {
|
||||||
switch (msg.type) {
|
return <AIMessage msg={msg} />;
|
||||||
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 useSetChatBoxContext = () => {
|
export const useSetChatBoxContext = () => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const api = useAPIClient();
|
|
||||||
const { aiEmployees } = useContext(AIEmployeesContext);
|
const { aiEmployees } = useContext(AIEmployeesContext);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null);
|
const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null);
|
||||||
const [currentConversation, setCurrentConversation] = useState<string>();
|
const { setMessages, sendMessages } = useChatMessages();
|
||||||
const { messages, setMessages, responseLoading, addMessage, sendMessages } = useChatMessages();
|
const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations();
|
||||||
const [senderValue, setSenderValue] = useState<string>('');
|
const [senderValue, setSenderValue] = useState<string>('');
|
||||||
const [senderPlaceholder, setSenderPlaceholder] = useState<string>('');
|
const [senderPlaceholder, setSenderPlaceholder] = useState<string>('');
|
||||||
const senderRef = useRef<GetRef<typeof Sender>>(null);
|
const senderRef = useRef<GetRef<typeof Sender>>(null);
|
||||||
@ -192,25 +138,12 @@ export const useSetChatBoxContext = () => {
|
|||||||
|
|
||||||
const infoForm = useMemo(() => createForm(), []);
|
const infoForm = useMemo(() => createForm(), []);
|
||||||
|
|
||||||
const conversations = useRequest<Conversation[]>(
|
|
||||||
() =>
|
|
||||||
api
|
|
||||||
.resource('aiConversations')
|
|
||||||
.list({
|
|
||||||
sort: ['-updatedAt'],
|
|
||||||
})
|
|
||||||
.then((res) => res?.data?.data),
|
|
||||||
{
|
|
||||||
ready: open,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const send = (options: SendOptions) => {
|
const send = (options: SendOptions) => {
|
||||||
const sendOptions = {
|
const sendOptions = {
|
||||||
...options,
|
...options,
|
||||||
onConversationCreate: (sessionId: string) => {
|
onConversationCreate: (sessionId: string) => {
|
||||||
setCurrentConversation(sessionId);
|
setCurrentConversation(sessionId);
|
||||||
conversations.refresh();
|
conversationsService.refresh();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const hasInfoFormValues = Object.values(infoForm?.values || []).filter(Boolean).length;
|
const hasInfoFormValues = Object.values(infoForm?.values || []).filter(Boolean).length;
|
||||||
@ -231,30 +164,6 @@ export const useSetChatBoxContext = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchAIEmployee = useCallback(
|
|
||||||
(aiEmployee: AIEmployee) => {
|
|
||||||
const greetingMsg = {
|
|
||||||
key: uid(),
|
|
||||||
role: aiEmployee.username,
|
|
||||||
content: {
|
|
||||||
type: 'greeting',
|
|
||||||
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setCurrentEmployee(aiEmployee);
|
|
||||||
setSenderPlaceholder(aiEmployee.chatSettings?.senderPlaceholder);
|
|
||||||
infoForm.reset();
|
|
||||||
senderRef.current?.focus();
|
|
||||||
if (!currentConversation) {
|
|
||||||
setMessages([greetingMsg]);
|
|
||||||
} else {
|
|
||||||
addMessage(greetingMsg);
|
|
||||||
setSenderValue('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentConversation, infoForm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const startNewConversation = useCallback(() => {
|
const startNewConversation = useCallback(() => {
|
||||||
setCurrentConversation(undefined);
|
setCurrentConversation(undefined);
|
||||||
setCurrentEmployee(null);
|
setCurrentEmployee(null);
|
||||||
@ -321,6 +230,7 @@ export const useSetChatBoxContext = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
conversationsService.run();
|
||||||
senderRef.current?.focus();
|
senderRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@ -330,26 +240,20 @@ export const useSetChatBoxContext = () => {
|
|||||||
setOpen,
|
setOpen,
|
||||||
currentEmployee,
|
currentEmployee,
|
||||||
setCurrentEmployee,
|
setCurrentEmployee,
|
||||||
conversations,
|
|
||||||
currentConversation,
|
|
||||||
setCurrentConversation,
|
|
||||||
messages,
|
|
||||||
setMessages,
|
|
||||||
roles,
|
roles,
|
||||||
responseLoading,
|
|
||||||
senderRef,
|
senderRef,
|
||||||
senderValue,
|
senderValue,
|
||||||
setSenderValue,
|
setSenderValue,
|
||||||
senderPlaceholder,
|
senderPlaceholder,
|
||||||
|
setSenderPlaceholder,
|
||||||
showInfoForm: !!currentEmployee?.chatSettings?.infoForm?.length,
|
showInfoForm: !!currentEmployee?.chatSettings?.infoForm?.length,
|
||||||
infoForm,
|
infoForm,
|
||||||
switchAIEmployee,
|
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
triggerShortcut,
|
triggerShortcut,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useChatBoxContext = (name: string) => {
|
export const useChatBoxContext = <K extends keyof ChatBoxContextValues>(name: K) => {
|
||||||
return useContextSelector(ChatBoxContext, (v) => v[name]);
|
return useContextSelector(ChatBoxContext, (v) => v[name]);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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, useRequest } from '@nocobase/client';
|
||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
import { Conversation } from '../types';
|
||||||
|
|
||||||
|
type ChatConversationContextValue = {
|
||||||
|
currentConversation?: string;
|
||||||
|
setCurrentConversation: (sessionId?: string) => void;
|
||||||
|
conversationsService: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatConversationsContext = createContext<ChatConversationContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useChatConversations = () => useContext(ChatConversationsContext);
|
||||||
|
|
||||||
|
export const ChatConversationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const [currentConversation, setCurrentConversation] = useState<string>();
|
||||||
|
|
||||||
|
const conversationsService = useRequest<Conversation[]>(
|
||||||
|
() =>
|
||||||
|
api
|
||||||
|
.resource('aiConversations')
|
||||||
|
.list({
|
||||||
|
sort: ['-updatedAt'],
|
||||||
|
appends: ['aiEmployee'],
|
||||||
|
})
|
||||||
|
.then((res) => res?.data?.data),
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
currentConversation,
|
||||||
|
setCurrentConversation,
|
||||||
|
conversationsService,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ChatConversationsContext.Provider value={value}>{children}</ChatConversationsContext.Provider>;
|
||||||
|
};
|
@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 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 { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||||
|
import { Message, SendOptions } from '../types'; // 假设有这些类型定义
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { useT } from '../../locale';
|
||||||
|
import { useAPIClient, useRequest } from '@nocobase/client';
|
||||||
|
import { useChatConversations } from './ChatConversationsProvider';
|
||||||
|
import { useLoadMoreObserver } from './useLoadMoreObserver';
|
||||||
|
|
||||||
|
interface ChatMessagesContextValue {
|
||||||
|
messages: Message[];
|
||||||
|
responseLoading: boolean;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
addMessages: (messages: Message[]) => void;
|
||||||
|
setMessages: (messages: Message[]) => void;
|
||||||
|
sendMessages: (
|
||||||
|
options: SendOptions & {
|
||||||
|
onConversationCreate?: (sessionId: string) => void;
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
messagesService: any;
|
||||||
|
lastMessageRef: React.RefObject<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessagesContext = createContext<ChatMessagesContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useChatMessages = () => useContext(ChatMessagesContext);
|
||||||
|
|
||||||
|
export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const t = useT();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [responseLoading, setResponseLoading] = useState(false);
|
||||||
|
const { currentConversation } = useChatConversations();
|
||||||
|
|
||||||
|
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;
|
||||||
|
updateLastMessage((last) => ({
|
||||||
|
...last,
|
||||||
|
content: {
|
||||||
|
...last.content,
|
||||||
|
content: (last.content as any).content + content,
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
updateLastMessage((last) => ({
|
||||||
|
...last,
|
||||||
|
role: 'error',
|
||||||
|
loading: false,
|
||||||
|
content: {
|
||||||
|
...last.content,
|
||||||
|
content: t(result),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessages = async ({
|
||||||
|
sessionId,
|
||||||
|
aiEmployee,
|
||||||
|
messages: sendMsgs,
|
||||||
|
infoFormValues,
|
||||||
|
onConversationCreate,
|
||||||
|
}: SendOptions & {
|
||||||
|
onConversationCreate?: (sessionId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const msgs: Message[] = [];
|
||||||
|
if (!sendMsgs.length) return;
|
||||||
|
|
||||||
|
if (infoFormValues) {
|
||||||
|
msgs.push({
|
||||||
|
key: uid(),
|
||||||
|
role: aiEmployee.username,
|
||||||
|
content: {
|
||||||
|
type: 'info',
|
||||||
|
content: infoFormValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs.push(
|
||||||
|
...sendMsgs.map((msg) => ({
|
||||||
|
key: uid(),
|
||||||
|
role: 'user',
|
||||||
|
content: msg,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
addMessages(msgs);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
const createRes = await api.resource('aiConversations').create({
|
||||||
|
values: { aiEmployee },
|
||||||
|
});
|
||||||
|
const conversation = createRes?.data?.data;
|
||||||
|
if (!conversation) return;
|
||||||
|
sessionId = conversation.sessionId;
|
||||||
|
onConversationCreate?.(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processStreamResponse(sendRes.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesService = useRequest<{
|
||||||
|
data: Message[];
|
||||||
|
meta: {
|
||||||
|
cursor?: string;
|
||||||
|
hasMore?: boolean;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
(cursor?: string) =>
|
||||||
|
api
|
||||||
|
.resource('aiConversations')
|
||||||
|
.getMessages({
|
||||||
|
sessionId: currentConversation,
|
||||||
|
cursor,
|
||||||
|
})
|
||||||
|
.then((res) => res?.data),
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
onSuccess: (data, params) => {
|
||||||
|
const cursor = params[0];
|
||||||
|
if (!data?.data?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newMessages = [...data.data].reverse();
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
return cursor ? [...newMessages, ...prev] : newMessages;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const messagesServiceRef = useRef<any>();
|
||||||
|
messagesServiceRef.current = messagesService;
|
||||||
|
const loadMoreMessages = useCallback(async () => {
|
||||||
|
const messagesService = messagesServiceRef.current;
|
||||||
|
if (messagesService.loading || !messagesService.data?.meta?.hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await messagesService.runAsync(messagesService.data?.meta?.cursor);
|
||||||
|
}, []);
|
||||||
|
const { ref: lastMessageRef } = useLoadMoreObserver({ loadMore: loadMoreMessages });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messagesServiceRef.current.run();
|
||||||
|
}, [currentConversation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatMessagesContext.Provider
|
||||||
|
value={{
|
||||||
|
messages,
|
||||||
|
responseLoading,
|
||||||
|
addMessage,
|
||||||
|
addMessages,
|
||||||
|
setMessages,
|
||||||
|
sendMessages,
|
||||||
|
messagesService,
|
||||||
|
lastMessageRef,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ChatMessagesContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -15,6 +15,7 @@ import { useAPIClient, useToken } from '@nocobase/client';
|
|||||||
import { useChatBoxContext } from './ChatBoxContext';
|
import { useChatBoxContext } from './ChatBoxContext';
|
||||||
import { useT } from '../../locale';
|
import { useT } from '../../locale';
|
||||||
import { DeleteOutlined } from '@ant-design/icons';
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { useChatConversations } from './ChatConversationsProvider';
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
|
|
||||||
export const Conversations: React.FC = () => {
|
export const Conversations: React.FC = () => {
|
||||||
@ -22,11 +23,11 @@ export const Conversations: React.FC = () => {
|
|||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const { modal, message } = App.useApp();
|
const { modal, message } = App.useApp();
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const conversationsService = useChatBoxContext('conversations');
|
const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations();
|
||||||
const currentConversation = useChatBoxContext('currentConversation');
|
|
||||||
const setCurrentConversation = useChatBoxContext('setCurrentConversation');
|
|
||||||
const setMessages = useChatBoxContext('setMessages');
|
|
||||||
const startNewConversation = useChatBoxContext('startNewConversation');
|
const startNewConversation = useChatBoxContext('startNewConversation');
|
||||||
|
const setCurrentEmployee = useChatBoxContext('setCurrentEmployee');
|
||||||
|
const setSenderValue = useChatBoxContext('setSenderValue');
|
||||||
|
const setSenderPlaceholder = useChatBoxContext('setSenderPlaceholder');
|
||||||
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
||||||
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
||||||
key: conversation.sessionId,
|
key: conversation.sessionId,
|
||||||
@ -43,15 +44,15 @@ export const Conversations: React.FC = () => {
|
|||||||
startNewConversation();
|
startNewConversation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessages = async (sessionId: string) => {
|
const selectConversation = (sessionId: string) => {
|
||||||
const res = await api.resource('aiConversations').getMessages({
|
if (sessionId === currentConversation) {
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
const messages = res?.data?.data;
|
|
||||||
if (!messages) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessages(messages.reverse());
|
setCurrentConversation(sessionId);
|
||||||
|
const conversation = conversationsRes.find((item) => item.sessionId === sessionId);
|
||||||
|
setCurrentEmployee(conversation?.aiEmployee);
|
||||||
|
setSenderValue('');
|
||||||
|
setSenderPlaceholder(conversation?.aiEmployee?.chatSettings?.senderPlaceholder);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,13 +72,7 @@ export const Conversations: React.FC = () => {
|
|||||||
{conversations && conversations.length ? (
|
{conversations && conversations.length ? (
|
||||||
<AntConversations
|
<AntConversations
|
||||||
activeKey={currentConversation}
|
activeKey={currentConversation}
|
||||||
onActiveChange={(sessionId) => {
|
onActiveChange={selectConversation}
|
||||||
if (sessionId === currentConversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentConversation(sessionId);
|
|
||||||
getMessages(sessionId);
|
|
||||||
}}
|
|
||||||
items={conversations}
|
items={conversations}
|
||||||
menu={(conversation) => ({
|
menu={(conversation) => ({
|
||||||
items: [
|
items: [
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Button, Space } from 'antd';
|
||||||
|
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { Bubble } from '@ant-design/x';
|
||||||
|
import { InfoFormMessage } from './InfoForm';
|
||||||
|
|
||||||
|
const MessageWrapper = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
>((props, ref) => {
|
||||||
|
if (ref) {
|
||||||
|
return <div ref={ref}>{props.children}</div>;
|
||||||
|
}
|
||||||
|
return props.children;
|
||||||
|
});
|
||||||
|
|
||||||
|
const AIMessageRenderer: React.FC<{
|
||||||
|
msg: any;
|
||||||
|
}> = ({ msg }) => {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return <Bubble content={<InfoFormMessage values={msg.content} />} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIMessage: React.FC<{
|
||||||
|
msg: any;
|
||||||
|
}> = memo(({ msg }) => {
|
||||||
|
return (
|
||||||
|
<MessageWrapper ref={msg.ref}>
|
||||||
|
<AIMessageRenderer msg={msg} />
|
||||||
|
</MessageWrapper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserMessage: React.FC<{
|
||||||
|
msg: any;
|
||||||
|
}> = memo(({ msg }) => {
|
||||||
|
return (
|
||||||
|
<MessageWrapper ref={msg.ref}>
|
||||||
|
<Bubble content={msg.content} />
|
||||||
|
</MessageWrapper>
|
||||||
|
);
|
||||||
|
});
|
@ -7,24 +7,55 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo, useEffect, useRef } from 'react';
|
||||||
import { Bubble } from '@ant-design/x';
|
import { Bubble } from '@ant-design/x';
|
||||||
import { useChatBoxContext } from './ChatBoxContext';
|
import { useChatBoxContext } from './ChatBoxContext';
|
||||||
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
||||||
|
import { Spin, Layout } from 'antd';
|
||||||
|
import { useChatMessages } from './ChatMessagesProvider';
|
||||||
|
|
||||||
export const Messages: React.FC = () => {
|
export const Messages: React.FC = () => {
|
||||||
const messages = useChatBoxContext('messages');
|
const { messages, messagesService, lastMessageRef } = useChatMessages();
|
||||||
const roles = useChatBoxContext('roles');
|
const roles = useChatBoxContext('roles');
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const lastMessageContent = messages[messages.length - 1]?.key;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lastMessageContent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Layout.Content
|
||||||
{messages?.length ? (
|
ref={contentRef}
|
||||||
<Bubble.List
|
style={{
|
||||||
|
margin: '16px 0',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messagesService.loading && (
|
||||||
|
<Spin
|
||||||
style={{
|
style={{
|
||||||
marginRight: '8px',
|
display: 'block',
|
||||||
|
margin: '8px auto',
|
||||||
}}
|
}}
|
||||||
roles={roles}
|
|
||||||
items={messages}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{messages?.length ? (
|
||||||
|
<div>
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
const role = roles[msg.role];
|
||||||
|
return index === 0 ? (
|
||||||
|
<div ref={lastMessageRef}>
|
||||||
|
<Bubble {...role} key={msg.key} content={msg.content} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Bubble {...role} key={msg.key} content={msg.content} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -38,6 +69,6 @@ export const Messages: React.FC = () => {
|
|||||||
<EmptyIcon />
|
<EmptyIcon />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</Layout.Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -11,21 +11,21 @@ import React, { memo } from 'react';
|
|||||||
import { Sender as AntSender } from '@ant-design/x';
|
import { Sender as AntSender } from '@ant-design/x';
|
||||||
import { useChatBoxContext } from './ChatBoxContext';
|
import { useChatBoxContext } from './ChatBoxContext';
|
||||||
import { SenderPrefix } from './SenderPrefix';
|
import { SenderPrefix } from './SenderPrefix';
|
||||||
import { Attachment } from './Attachment';
|
|
||||||
import { AIEmployeeHeader } from './AIEmployeeHeader';
|
|
||||||
import { useT } from '../../locale';
|
import { useT } from '../../locale';
|
||||||
import { SenderHeader } from './SenderHeader';
|
import { SenderHeader } from './SenderHeader';
|
||||||
import { SenderFooter } from './SenderFooter';
|
import { SenderFooter } from './SenderFooter';
|
||||||
|
import { useChatConversations } from './ChatConversationsProvider';
|
||||||
|
import { useChatMessages } from './ChatMessagesProvider';
|
||||||
|
|
||||||
export const Sender: React.FC = memo(() => {
|
export const Sender: React.FC = memo(() => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { currentConversation } = useChatConversations();
|
||||||
|
const { responseLoading } = useChatMessages();
|
||||||
const senderValue = useChatBoxContext('senderValue');
|
const senderValue = useChatBoxContext('senderValue');
|
||||||
const setSenderValue = useChatBoxContext('setSenderValue');
|
const setSenderValue = useChatBoxContext('setSenderValue');
|
||||||
const senderPlaceholder = useChatBoxContext('senderPlaceholder');
|
const senderPlaceholder = useChatBoxContext('senderPlaceholder');
|
||||||
const send = useChatBoxContext('send');
|
const send = useChatBoxContext('send');
|
||||||
const currentConversation = useChatBoxContext('currentConversation');
|
|
||||||
const currentEmployee = useChatBoxContext('currentEmployee');
|
const currentEmployee = useChatBoxContext('currentEmployee');
|
||||||
const responseLoading = useChatBoxContext('responseLoading');
|
|
||||||
const showInfoForm = useChatBoxContext('showInfoForm');
|
const showInfoForm = useChatBoxContext('showInfoForm');
|
||||||
const senderRef = useChatBoxContext('senderRef');
|
const senderRef = useChatBoxContext('senderRef');
|
||||||
return (
|
return (
|
||||||
|
@ -9,16 +9,14 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Divider, Flex } from 'antd';
|
import { Divider, Flex } from 'antd';
|
||||||
import { useChatBoxContext } from './ChatBoxContext';
|
|
||||||
import { FieldSelector } from './FieldSelector';
|
import { FieldSelector } from './FieldSelector';
|
||||||
import { useT } from '../../locale';
|
import { useChatMessages } from './ChatMessagesProvider';
|
||||||
|
|
||||||
export const SenderFooter: React.FC<{
|
export const SenderFooter: React.FC<{
|
||||||
components: any;
|
components: any;
|
||||||
}> = ({ components }) => {
|
}> = ({ components }) => {
|
||||||
const t = useT();
|
|
||||||
const { SendButton, LoadingButton } = components;
|
const { SendButton, LoadingButton } = components;
|
||||||
const { responseLoading: loading } = useChatBoxContext('responseLoading');
|
const { responseLoading: loading } = useChatMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
|
@ -1,194 +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 { 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 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,
|
|
||||||
infoFormValues,
|
|
||||||
onConversationCreate,
|
|
||||||
}: SendOptions & {
|
|
||||||
onConversationCreate?: (sessionId: string) => void;
|
|
||||||
}) => {
|
|
||||||
const msgs: Message[] = [];
|
|
||||||
if (!sendMsgs.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (infoFormValues) {
|
|
||||||
msgs.push({
|
|
||||||
key: uid(),
|
|
||||||
role: 'info',
|
|
||||||
content: {
|
|
||||||
type: 'info',
|
|
||||||
content: infoFormValues,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
addMessage,
|
|
||||||
addMessages,
|
|
||||||
setMessages,
|
|
||||||
responseLoading,
|
|
||||||
sendMessages,
|
|
||||||
};
|
|
||||||
};
|
|
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useLoadMoreObserver = ({ loadMore }: { loadMore: () => void }) => {
|
||||||
|
const lastElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
const setLastElementRef = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
lastElementRef.current = node;
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
|
||||||
|
observerRef.current = new IntersectionObserver((entries) => {
|
||||||
|
const [entry] = entries;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
observerRef.current?.unobserve(entry.target);
|
||||||
|
loadMore();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (lastElementRef.current) {
|
||||||
|
observerRef.current?.observe(lastElementRef.current);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observerRef.current.observe(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadMore],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ref: setLastElementRef };
|
||||||
|
};
|
@ -64,6 +64,9 @@ export const ProfileSettings: React.FC = () => {
|
|||||||
title: 'Greeting message',
|
title: 'Greeting message',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input.TextArea',
|
'x-component': 'Input.TextArea',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: 'Opening message sent to the user when starting a new conversation.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
@ -33,6 +33,7 @@ export type Conversation = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
aiEmployee: AIEmployee;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AttachmentType = 'image' | 'uiSchema';
|
export type AttachmentType = 'image' | 'uiSchema';
|
||||||
@ -43,8 +44,15 @@ export type AttachmentProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageType = 'text' | AttachmentType;
|
export type MessageType = 'text' | 'greeting' | 'info' | AttachmentType;
|
||||||
export type Message = BubbleProps & { key?: string | number; role?: string };
|
export type Message = Omit<BubbleProps, 'content'> & {
|
||||||
|
key?: string | number;
|
||||||
|
role?: string;
|
||||||
|
content: {
|
||||||
|
type: MessageType;
|
||||||
|
content: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
export type Action = {
|
export type Action = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
content: string;
|
content: string;
|
||||||
|
@ -31,14 +31,11 @@ export default defineCollection({
|
|||||||
foreignKey: 'userId',
|
foreignKey: 'userId',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'aiEmployees',
|
name: 'aiEmployee',
|
||||||
type: 'belongsToMany',
|
type: 'belongsTo',
|
||||||
target: 'aiEmployees',
|
target: 'aiEmployees',
|
||||||
through: 'aiConversationsEmployees',
|
|
||||||
foreignKey: 'username',
|
|
||||||
otherKey: 'sessionId',
|
|
||||||
targetKey: 'username',
|
targetKey: 'username',
|
||||||
sourceKey: 'sessionId',
|
foreignKey: 'aiEmployeeUsername',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
import actions, { Context, Next } from '@nocobase/actions';
|
import actions, { Context, Next } from '@nocobase/actions';
|
||||||
import snowflake from '../snowflake';
|
import snowflake from '../snowflake';
|
||||||
import PluginAIServer from '../plugin';
|
import PluginAIServer from '../plugin';
|
||||||
import { convertUiSchemaToJsonSchema } from '../utils';
|
|
||||||
import { Database, Model } from '@nocobase/database';
|
import { Database, Model } from '@nocobase/database';
|
||||||
|
|
||||||
async function parseInfoMessage(db: Database, aiEmployee: Model, content: Record<string, any>) {
|
async function parseInfoMessage(db: Database, aiEmployee: Model, content: Record<string, any>) {
|
||||||
@ -28,6 +27,9 @@ async function parseInfoMessage(db: Database, aiEmployee: Model, content: Record
|
|||||||
let info = '';
|
let info = '';
|
||||||
for (const key in content) {
|
for (const key in content) {
|
||||||
const field = infoForm.find((item) => item.name === key);
|
const field = infoForm.find((item) => item.name === key);
|
||||||
|
if (!field) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (field.type === 'blocks') {
|
if (field.type === 'blocks') {
|
||||||
const uiSchemaRepo = db.getRepository('uiSchemas') as any;
|
const uiSchemaRepo = db.getRepository('uiSchemas') as any;
|
||||||
const schema = await uiSchemaRepo.getJsonSchema(content[key]);
|
const schema = await uiSchemaRepo.getJsonSchema(content[key]);
|
||||||
@ -62,13 +64,13 @@ export default {
|
|||||||
},
|
},
|
||||||
async create(ctx: Context, next: Next) {
|
async create(ctx: Context, next: Next) {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
const { aiEmployees } = ctx.action.params.values || {};
|
const { aiEmployee } = ctx.action.params.values || {};
|
||||||
const repo = ctx.db.getRepository('aiConversations');
|
const repo = ctx.db.getRepository('aiConversations');
|
||||||
ctx.body = await repo.create({
|
ctx.body = await repo.create({
|
||||||
values: {
|
values: {
|
||||||
title: 'New Conversation',
|
title: 'New Conversation',
|
||||||
userId,
|
userId,
|
||||||
aiEmployees,
|
aiEmployee,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await next();
|
await next();
|
||||||
@ -90,7 +92,7 @@ export default {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return ctx.throw(403);
|
return ctx.throw(403);
|
||||||
}
|
}
|
||||||
const { sessionId } = ctx.action.params || {};
|
const { sessionId, cursor } = ctx.action.params || {};
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
ctx.throw(400);
|
ctx.throw(400);
|
||||||
}
|
}
|
||||||
@ -103,10 +105,32 @@ export default {
|
|||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
ctx.throw(400);
|
ctx.throw(400);
|
||||||
}
|
}
|
||||||
ctx.body = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
|
const pageSize = 10;
|
||||||
|
const rows = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
|
||||||
sort: ['-messageId'],
|
sort: ['-messageId'],
|
||||||
limit: 10,
|
limit: pageSize + 1,
|
||||||
|
...(cursor
|
||||||
|
? {
|
||||||
|
filter: {
|
||||||
|
messageId: {
|
||||||
|
$lt: cursor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
const hasMore = rows.length > pageSize;
|
||||||
|
const data = hasMore ? rows.slice(0, -1) : rows;
|
||||||
|
const newCursor = data.length ? data[data.length - 1].messageId : null;
|
||||||
|
ctx.body = {
|
||||||
|
rows: data.map((row: Model) => ({
|
||||||
|
key: row.messageId,
|
||||||
|
content: row.content,
|
||||||
|
role: row.role,
|
||||||
|
})),
|
||||||
|
hasMore,
|
||||||
|
cursor: newCursor,
|
||||||
|
};
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async sendMessages(ctx: Context, next: Next) {
|
async sendMessages(ctx: Context, next: Next) {
|
||||||
@ -183,14 +207,8 @@ export default {
|
|||||||
if (msg.content.type === 'info') {
|
if (msg.content.type === 'info') {
|
||||||
content = await parseInfoMessage(ctx.db, employee, content);
|
content = await parseInfoMessage(ctx.db, employee, content);
|
||||||
}
|
}
|
||||||
let role = msg.role;
|
|
||||||
if (role === 'info' || role === 'user') {
|
|
||||||
role = 'user';
|
|
||||||
} else {
|
|
||||||
role = 'ai';
|
|
||||||
}
|
|
||||||
history.push({
|
history.push({
|
||||||
role,
|
role: msg.role === 'user' ? 'user' : 'ai',
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -211,9 +229,6 @@ export default {
|
|||||||
ctx.status = 200;
|
ctx.status = 200;
|
||||||
const userMessages = [];
|
const userMessages = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.role !== 'user' && msg.role !== 'info') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let content = msg.content.content;
|
let content = msg.content.content;
|
||||||
if (msg.content.type === 'info') {
|
if (msg.content.type === 'info') {
|
||||||
content = await parseInfoMessage(ctx.db, employee, content);
|
content = await parseInfoMessage(ctx.db, employee, content);
|
||||||
@ -222,7 +237,7 @@ export default {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
userMessages.push({
|
userMessages.push({
|
||||||
role: 'user',
|
role: msg.role === 'user' ? 'user' : 'ai',
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user