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 { AIEmployee } from './types';
|
||||
import { AISelectionProvider } from './selector/AISelectorProvider';
|
||||
import { ChatMessagesProvider } from './chatbox/ChatMessagesProvider';
|
||||
import { ChatConversationsProvider } from './chatbox/ChatConversationsProvider';
|
||||
|
||||
export const AIEmployeesContext = createContext<{
|
||||
aiEmployees: AIEmployee[];
|
||||
@ -27,7 +29,11 @@ export const AIEmployeesProvider: React.FC<{
|
||||
return (
|
||||
<AISelectionProvider>
|
||||
<AIEmployeesContext.Provider value={{ aiEmployees, setAIEmployees }}>
|
||||
<ChatBoxProvider>{props.children}</ChatBoxProvider>
|
||||
<ChatConversationsProvider>
|
||||
<ChatMessagesProvider>
|
||||
<ChatBoxProvider>{props.children}</ChatBoxProvider>
|
||||
</ChatMessagesProvider>
|
||||
</ChatConversationsProvider>
|
||||
</AIEmployeesContext.Provider>
|
||||
</AISelectionProvider>
|
||||
);
|
||||
|
@ -7,7 +7,7 @@
|
||||
* 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 { useToken } from '@nocobase/client';
|
||||
import { useAIEmployeesContext } from '../AIEmployeesProvider';
|
||||
@ -17,13 +17,48 @@ import { avatars } from '../avatars';
|
||||
import { css } from '@emotion/css';
|
||||
import { Sender } from '@ant-design/x';
|
||||
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 = () => {
|
||||
const {
|
||||
service: { loading },
|
||||
aiEmployees,
|
||||
} = 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 (
|
||||
<Sender.Header closable={false}>
|
||||
<List
|
||||
|
@ -9,9 +9,9 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
const { Header, Footer, Sider, Content } = Layout;
|
||||
const { Header, Footer, Sider } = Layout;
|
||||
import { useChatBoxContext } from './ChatBoxContext';
|
||||
import { Conversations } from './Conversations';
|
||||
import { Messages } from './Messages';
|
||||
@ -24,25 +24,37 @@ export const ChatBox: React.FC = () => {
|
||||
const currentEmployee = useChatBoxContext('currentEmployee');
|
||||
const { token } = useToken();
|
||||
const [showConversations, setShowConversations] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { selectable } = useAISelectionContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '16px',
|
||||
bottom: '16px',
|
||||
width: '90%',
|
||||
maxWidth: '760px',
|
||||
height: '90%',
|
||||
maxHeight: '560px',
|
||||
zIndex: selectable ? -1 : 1000,
|
||||
}}
|
||||
style={
|
||||
!expanded
|
||||
? {
|
||||
position: 'fixed',
|
||||
right: '16px',
|
||||
bottom: '16px',
|
||||
width: '90%',
|
||||
maxWidth: '760px',
|
||||
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 } }}>
|
||||
<Layout style={{ height: '100%' }}>
|
||||
<Sider
|
||||
width="30%"
|
||||
width={!expanded ? '30%' : '15%'}
|
||||
style={{
|
||||
display: showConversations ? 'block' : 'none',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
@ -78,19 +90,15 @@ export const ChatBox: React.FC = () => {
|
||||
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)} />
|
||||
</div>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: '16px 0',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Messages />
|
||||
</Content>
|
||||
<Messages />
|
||||
<Footer
|
||||
style={{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
|
@ -7,60 +7,43 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import {
|
||||
AttachmentProps,
|
||||
Conversation,
|
||||
Message,
|
||||
Action,
|
||||
SendOptions,
|
||||
AIEmployee,
|
||||
MessageType,
|
||||
ShortcutOptions,
|
||||
} from '../types';
|
||||
import { Avatar, GetProp, GetRef, Button, Alert, Space, Popover } from 'antd';
|
||||
import { Conversation, Message, SendOptions, AIEmployee, ShortcutOptions } from '../types';
|
||||
import { Avatar, GetProp, GetRef, Button, Alert, Popover } from 'antd';
|
||||
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 { useAPIClient, useRequest } from '@nocobase/client';
|
||||
import { useAPIClient } from '@nocobase/client';
|
||||
import { AIEmployeesContext } from '../AIEmployeesProvider';
|
||||
import { Attachment } from './Attachment';
|
||||
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { avatars } from '../avatars';
|
||||
import { useChatMessages } from './useChatMessages';
|
||||
import { uid } from '@formily/shared';
|
||||
import { useT } from '../../locale';
|
||||
import { createForm, Form } from '@formily/core';
|
||||
import { ProfileCard } from '../ProfileCard';
|
||||
import { InfoFormMessage } from './InfoForm';
|
||||
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;
|
||||
open: boolean;
|
||||
currentEmployee: 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 };
|
||||
responseLoading: boolean;
|
||||
senderValue: string;
|
||||
setSenderValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
senderRef: React.MutableRefObject<GetRef<typeof Sender>>;
|
||||
senderPlaceholder: string;
|
||||
setSenderPlaceholder: React.Dispatch<React.SetStateAction<string>>;
|
||||
infoForm: Form;
|
||||
showInfoForm: boolean;
|
||||
switchAIEmployee: (aiEmployee: AIEmployee) => void;
|
||||
startNewConversation: () => void;
|
||||
triggerShortcut: (options: ShortcutOptions) => void;
|
||||
send(opts: SendOptions): void;
|
||||
}>({} as any);
|
||||
};
|
||||
|
||||
export const ChatBoxContext = createContext<ChatBoxContextValues>({} as any);
|
||||
|
||||
const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
||||
user: {
|
||||
@ -68,16 +51,12 @@ const defaultRoles: GetProp<typeof Bubble.List, 'roles'> = {
|
||||
styles: {
|
||||
content: {
|
||||
maxWidth: '400px',
|
||||
margin: '8px 8px 8px 0',
|
||||
},
|
||||
},
|
||||
variant: 'borderless',
|
||||
messageRender: (msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'text':
|
||||
return <Bubble content={msg.content} />;
|
||||
default:
|
||||
return <Attachment {...msg} />;
|
||||
}
|
||||
return <UserMessage msg={msg} />;
|
||||
},
|
||||
},
|
||||
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: {
|
||||
placement: 'start',
|
||||
avatar: { icon: '', style: { visibility: 'hidden' } },
|
||||
@ -148,8 +109,9 @@ const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
||||
) : null,
|
||||
typing: { step: 5, interval: 20 },
|
||||
style: {
|
||||
maxWidth: 400,
|
||||
maxWidth: '80%',
|
||||
marginInlineEnd: 48,
|
||||
margin: '8px 0',
|
||||
},
|
||||
styles: {
|
||||
footer: {
|
||||
@ -158,33 +120,17 @@ const aiEmployeeRole = (aiEmployee: AIEmployee) => ({
|
||||
},
|
||||
variant: 'borderless',
|
||||
messageRender: (msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'greeting':
|
||||
return <Bubble content={msg.content} />;
|
||||
case 'text':
|
||||
return (
|
||||
<Bubble
|
||||
content={msg.content}
|
||||
footer={
|
||||
<Space>
|
||||
<Button color="default" variant="text" size="small" icon={<ReloadOutlined />} />
|
||||
<Button color="default" variant="text" size="small" icon={<CopyOutlined />} />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AIMessage msg={msg} />;
|
||||
},
|
||||
});
|
||||
|
||||
export const useSetChatBoxContext = () => {
|
||||
const t = useT();
|
||||
const api = useAPIClient();
|
||||
const { aiEmployees } = useContext(AIEmployeesContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentEmployee, setCurrentEmployee] = useState<AIEmployee>(null);
|
||||
const [currentConversation, setCurrentConversation] = useState<string>();
|
||||
const { messages, setMessages, responseLoading, addMessage, sendMessages } = useChatMessages();
|
||||
const { setMessages, sendMessages } = useChatMessages();
|
||||
const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations();
|
||||
const [senderValue, setSenderValue] = useState<string>('');
|
||||
const [senderPlaceholder, setSenderPlaceholder] = useState<string>('');
|
||||
const senderRef = useRef<GetRef<typeof Sender>>(null);
|
||||
@ -192,25 +138,12 @@ export const useSetChatBoxContext = () => {
|
||||
|
||||
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 sendOptions = {
|
||||
...options,
|
||||
onConversationCreate: (sessionId: string) => {
|
||||
setCurrentConversation(sessionId);
|
||||
conversations.refresh();
|
||||
conversationsService.refresh();
|
||||
},
|
||||
};
|
||||
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(() => {
|
||||
setCurrentConversation(undefined);
|
||||
setCurrentEmployee(null);
|
||||
@ -321,6 +230,7 @@ export const useSetChatBoxContext = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
conversationsService.run();
|
||||
senderRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
@ -330,26 +240,20 @@ export const useSetChatBoxContext = () => {
|
||||
setOpen,
|
||||
currentEmployee,
|
||||
setCurrentEmployee,
|
||||
conversations,
|
||||
currentConversation,
|
||||
setCurrentConversation,
|
||||
messages,
|
||||
setMessages,
|
||||
roles,
|
||||
responseLoading,
|
||||
senderRef,
|
||||
senderValue,
|
||||
setSenderValue,
|
||||
senderPlaceholder,
|
||||
setSenderPlaceholder,
|
||||
showInfoForm: !!currentEmployee?.chatSettings?.infoForm?.length,
|
||||
infoForm,
|
||||
switchAIEmployee,
|
||||
startNewConversation,
|
||||
triggerShortcut,
|
||||
send,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatBoxContext = (name: string) => {
|
||||
export const useChatBoxContext = <K extends keyof ChatBoxContextValues>(name: K) => {
|
||||
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 { useT } from '../../locale';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useChatConversations } from './ChatConversationsProvider';
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
export const Conversations: React.FC = () => {
|
||||
@ -22,11 +23,11 @@ export const Conversations: React.FC = () => {
|
||||
const api = useAPIClient();
|
||||
const { modal, message } = App.useApp();
|
||||
const { token } = useToken();
|
||||
const conversationsService = useChatBoxContext('conversations');
|
||||
const currentConversation = useChatBoxContext('currentConversation');
|
||||
const setCurrentConversation = useChatBoxContext('setCurrentConversation');
|
||||
const setMessages = useChatBoxContext('setMessages');
|
||||
const { currentConversation, setCurrentConversation, conversationsService } = useChatConversations();
|
||||
const startNewConversation = useChatBoxContext('startNewConversation');
|
||||
const setCurrentEmployee = useChatBoxContext('setCurrentEmployee');
|
||||
const setSenderValue = useChatBoxContext('setSenderValue');
|
||||
const setSenderPlaceholder = useChatBoxContext('setSenderPlaceholder');
|
||||
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
|
||||
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
|
||||
key: conversation.sessionId,
|
||||
@ -43,15 +44,15 @@ export const Conversations: React.FC = () => {
|
||||
startNewConversation();
|
||||
};
|
||||
|
||||
const getMessages = async (sessionId: string) => {
|
||||
const res = await api.resource('aiConversations').getMessages({
|
||||
sessionId,
|
||||
});
|
||||
const messages = res?.data?.data;
|
||||
if (!messages) {
|
||||
const selectConversation = (sessionId: string) => {
|
||||
if (sessionId === currentConversation) {
|
||||
return;
|
||||
}
|
||||
setMessages(messages.reverse());
|
||||
setCurrentConversation(sessionId);
|
||||
const conversation = conversationsRes.find((item) => item.sessionId === sessionId);
|
||||
setCurrentEmployee(conversation?.aiEmployee);
|
||||
setSenderValue('');
|
||||
setSenderPlaceholder(conversation?.aiEmployee?.chatSettings?.senderPlaceholder);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -71,13 +72,7 @@ export const Conversations: React.FC = () => {
|
||||
{conversations && conversations.length ? (
|
||||
<AntConversations
|
||||
activeKey={currentConversation}
|
||||
onActiveChange={(sessionId) => {
|
||||
if (sessionId === currentConversation) {
|
||||
return;
|
||||
}
|
||||
setCurrentConversation(sessionId);
|
||||
getMessages(sessionId);
|
||||
}}
|
||||
onActiveChange={selectConversation}
|
||||
items={conversations}
|
||||
menu={(conversation) => ({
|
||||
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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { Bubble } from '@ant-design/x';
|
||||
import { useChatBoxContext } from './ChatBoxContext';
|
||||
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
|
||||
import { Spin, Layout } from 'antd';
|
||||
import { useChatMessages } from './ChatMessagesProvider';
|
||||
|
||||
export const Messages: React.FC = () => {
|
||||
const messages = useChatBoxContext('messages');
|
||||
const { messages, messagesService, lastMessageRef } = useChatMessages();
|
||||
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 (
|
||||
<>
|
||||
{messages?.length ? (
|
||||
<Bubble.List
|
||||
<Layout.Content
|
||||
ref={contentRef}
|
||||
style={{
|
||||
margin: '16px 0',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{messagesService.loading && (
|
||||
<Spin
|
||||
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
|
||||
style={{
|
||||
@ -38,6 +69,6 @@ export const Messages: React.FC = () => {
|
||||
<EmptyIcon />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Layout.Content>
|
||||
);
|
||||
};
|
||||
|
@ -11,21 +11,21 @@ import React, { memo } from 'react';
|
||||
import { Sender as AntSender } from '@ant-design/x';
|
||||
import { useChatBoxContext } from './ChatBoxContext';
|
||||
import { SenderPrefix } from './SenderPrefix';
|
||||
import { Attachment } from './Attachment';
|
||||
import { AIEmployeeHeader } from './AIEmployeeHeader';
|
||||
import { useT } from '../../locale';
|
||||
import { SenderHeader } from './SenderHeader';
|
||||
import { SenderFooter } from './SenderFooter';
|
||||
import { useChatConversations } from './ChatConversationsProvider';
|
||||
import { useChatMessages } from './ChatMessagesProvider';
|
||||
|
||||
export const Sender: React.FC = memo(() => {
|
||||
const t = useT();
|
||||
const { currentConversation } = useChatConversations();
|
||||
const { responseLoading } = useChatMessages();
|
||||
const senderValue = useChatBoxContext('senderValue');
|
||||
const setSenderValue = useChatBoxContext('setSenderValue');
|
||||
const senderPlaceholder = useChatBoxContext('senderPlaceholder');
|
||||
const send = useChatBoxContext('send');
|
||||
const currentConversation = useChatBoxContext('currentConversation');
|
||||
const currentEmployee = useChatBoxContext('currentEmployee');
|
||||
const responseLoading = useChatBoxContext('responseLoading');
|
||||
const showInfoForm = useChatBoxContext('showInfoForm');
|
||||
const senderRef = useChatBoxContext('senderRef');
|
||||
return (
|
||||
|
@ -9,16 +9,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Divider, Flex } from 'antd';
|
||||
import { useChatBoxContext } from './ChatBoxContext';
|
||||
import { FieldSelector } from './FieldSelector';
|
||||
import { useT } from '../../locale';
|
||||
import { useChatMessages } from './ChatMessagesProvider';
|
||||
|
||||
export const SenderFooter: React.FC<{
|
||||
components: any;
|
||||
}> = ({ components }) => {
|
||||
const t = useT();
|
||||
const { SendButton, LoadingButton } = components;
|
||||
const { responseLoading: loading } = useChatBoxContext('responseLoading');
|
||||
const { responseLoading: loading } = useChatMessages();
|
||||
|
||||
return (
|
||||
<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',
|
||||
'x-decorator': 'FormItem',
|
||||
'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;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
aiEmployee: AIEmployee;
|
||||
};
|
||||
|
||||
export type AttachmentType = 'image' | 'uiSchema';
|
||||
@ -43,8 +44,15 @@ export type AttachmentProps = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type MessageType = 'text' | AttachmentType;
|
||||
export type Message = BubbleProps & { key?: string | number; role?: string };
|
||||
export type MessageType = 'text' | 'greeting' | 'info' | AttachmentType;
|
||||
export type Message = Omit<BubbleProps, 'content'> & {
|
||||
key?: string | number;
|
||||
role?: string;
|
||||
content: {
|
||||
type: MessageType;
|
||||
content: any;
|
||||
};
|
||||
};
|
||||
export type Action = {
|
||||
icon?: React.ReactNode;
|
||||
content: string;
|
||||
|
@ -31,14 +31,11 @@ export default defineCollection({
|
||||
foreignKey: 'userId',
|
||||
},
|
||||
{
|
||||
name: 'aiEmployees',
|
||||
type: 'belongsToMany',
|
||||
name: 'aiEmployee',
|
||||
type: 'belongsTo',
|
||||
target: 'aiEmployees',
|
||||
through: 'aiConversationsEmployees',
|
||||
foreignKey: 'username',
|
||||
otherKey: 'sessionId',
|
||||
targetKey: 'username',
|
||||
sourceKey: 'sessionId',
|
||||
foreignKey: 'aiEmployeeUsername',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
|
@ -10,7 +10,6 @@
|
||||
import actions, { Context, Next } from '@nocobase/actions';
|
||||
import snowflake from '../snowflake';
|
||||
import PluginAIServer from '../plugin';
|
||||
import { convertUiSchemaToJsonSchema } from '../utils';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
|
||||
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 = '';
|
||||
for (const key in content) {
|
||||
const field = infoForm.find((item) => item.name === key);
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
if (field.type === 'blocks') {
|
||||
const uiSchemaRepo = db.getRepository('uiSchemas') as any;
|
||||
const schema = await uiSchemaRepo.getJsonSchema(content[key]);
|
||||
@ -62,13 +64,13 @@ export default {
|
||||
},
|
||||
async create(ctx: Context, next: Next) {
|
||||
const userId = ctx.auth?.user.id;
|
||||
const { aiEmployees } = ctx.action.params.values || {};
|
||||
const { aiEmployee } = ctx.action.params.values || {};
|
||||
const repo = ctx.db.getRepository('aiConversations');
|
||||
ctx.body = await repo.create({
|
||||
values: {
|
||||
title: 'New Conversation',
|
||||
userId,
|
||||
aiEmployees,
|
||||
aiEmployee,
|
||||
},
|
||||
});
|
||||
await next();
|
||||
@ -90,7 +92,7 @@ export default {
|
||||
if (!userId) {
|
||||
return ctx.throw(403);
|
||||
}
|
||||
const { sessionId } = ctx.action.params || {};
|
||||
const { sessionId, cursor } = ctx.action.params || {};
|
||||
if (!sessionId) {
|
||||
ctx.throw(400);
|
||||
}
|
||||
@ -103,10 +105,32 @@ export default {
|
||||
if (!conversation) {
|
||||
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'],
|
||||
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();
|
||||
},
|
||||
async sendMessages(ctx: Context, next: Next) {
|
||||
@ -183,14 +207,8 @@ export default {
|
||||
if (msg.content.type === 'info') {
|
||||
content = await parseInfoMessage(ctx.db, employee, content);
|
||||
}
|
||||
let role = msg.role;
|
||||
if (role === 'info' || role === 'user') {
|
||||
role = 'user';
|
||||
} else {
|
||||
role = 'ai';
|
||||
}
|
||||
history.push({
|
||||
role,
|
||||
role: msg.role === 'user' ? 'user' : 'ai',
|
||||
content,
|
||||
});
|
||||
}
|
||||
@ -211,9 +229,6 @@ export default {
|
||||
ctx.status = 200;
|
||||
const userMessages = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'user' && msg.role !== 'info') {
|
||||
continue;
|
||||
}
|
||||
let content = msg.content.content;
|
||||
if (msg.content.type === 'info') {
|
||||
content = await parseInfoMessage(ctx.db, employee, content);
|
||||
@ -222,7 +237,7 @@ export default {
|
||||
continue;
|
||||
}
|
||||
userMessages.push({
|
||||
role: 'user',
|
||||
role: msg.role === 'user' ? 'user' : 'ai',
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user