chore: update

This commit is contained in:
xilesun 2025-04-11 13:08:34 +08:00
parent f60cd60c33
commit 421b2827d6
18 changed files with 630 additions and 400 deletions

View File

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

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

@ -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.',
},
}, },
}, },
}} }}

View File

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

View File

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

View File

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