From e79359c683bf6e2f05fda94a876892f3d246edac Mon Sep 17 00:00:00 2001 From: xilesun <2013xile@gmail.com> Date: Fri, 4 Apr 2025 21:33:32 +0800 Subject: [PATCH] chore: optimize code --- .../ai-employees/AIEmployeeChatProvider.tsx | 37 +++ .../ai-employees/AIEmployeesProvider.tsx | 9 +- .../ai-employees/chatbox/Attachment.tsx | 11 +- .../client/ai-employees/chatbox/ChatBox.tsx | 59 ++-- .../ai-employees/chatbox/ChatBoxContext.tsx | 245 ++++++++++++++ .../ai-employees/chatbox/ChatBoxProvider.tsx | 311 +----------------- .../ai-employees/chatbox/useChatMessages.ts | 216 ++++++++++++ .../initializer/AIEmployeeButton.tsx | 125 ++++--- .../ai-employees/initializer/AIEmployees.tsx | 70 ++++ .../initializer/ConfigureAIEmployees.tsx | 78 ----- .../client/ai-employees/manager/Employees.tsx | 29 +- .../settings/AIEmployeeButton.tsx | 146 +++++++- .../src/client/ai-employees/types.ts | 50 +++ .../ai-employees/useBlockChatContext.tsx | 47 +++ .../@nocobase/plugin-ai/src/client/index.tsx | 23 +- .../@nocobase/plugin-ai/src/client/locale.ts | 2 +- .../@nocobase/plugin-ai/src/locale/en-US.json | 3 +- .../src/server/collections/ai-messages.ts | 4 + .../src/server/resource/aiConversations.ts | 59 +++- 19 files changed, 1018 insertions(+), 506 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeeChatProvider.tsx create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBoxContext.tsx create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/useChatMessages.ts create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployees.tsx delete mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/ConfigureAIEmployees.tsx create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/useBlockChatContext.tsx diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeeChatProvider.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeeChatProvider.tsx new file mode 100644 index 0000000000..3f4d6aa565 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeeChatProvider.tsx @@ -0,0 +1,37 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useContext } from 'react'; +import { withDynamicSchemaProps } from '@nocobase/client'; +import { createContext } from 'react'; +import { AttachmentProps } from './types'; + +export type AIEmployeeChatContext = { + attachments?: Record; + actions?: Record< + string, + { + title: string; + description?: string; + icon?: React.ReactNode; + action: (aiMessage: string) => void; + } + >; + variableScopes?: any; +}; + +export const AIEmployeeChatContext = createContext({} as AIEmployeeChatContext); + +export const AIEmployeeChatProvider: React.FC = withDynamicSchemaProps((props) => { + return {props.children}; +}); + +export const useAIEmployeeChatContext = () => { + return useContext(AIEmployeeChatContext); +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeesProvider.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeesProvider.tsx index 073e5d4591..2b5ee907eb 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeesProvider.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/AIEmployeesProvider.tsx @@ -11,14 +11,7 @@ import React, { useContext } from 'react'; import { createContext } from 'react'; import { ChatBoxProvider } from './chatbox/ChatBoxProvider'; import { useAPIClient, useRequest } from '@nocobase/client'; - -export type AIEmployee = { - username: string; - nickname?: string; - avatar?: string; - bio?: string; - greeting?: string; -}; +import { AIEmployee } from './types'; export const AIEmployeesContext = createContext<{ aiEmployees: AIEmployee[]; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/Attachment.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/Attachment.tsx index 81d18a1ce0..6963d4b930 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/Attachment.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/Attachment.tsx @@ -10,25 +10,20 @@ import React from 'react'; import { Tag } from 'antd'; import { BuildOutlined } from '@ant-design/icons'; - -export type AttachmentType = 'image' | 'uiSchema'; -export type AttachmentProps = { - type: AttachmentType; - content: string; -}; +import { AttachmentProps } from '../types'; export const Attachment: React.FC< AttachmentProps & { closeable?: boolean; onClose?: () => void; } -> = ({ type, content, closeable, onClose }) => { +> = ({ type, title, content, closeable, onClose }) => { let prefix: React.ReactNode; switch (type) { case 'uiSchema': prefix = ( <> - UI Schema {'>'}{' '} + {title} {'>'}{' '} ); break; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBox.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBox.tsx index 0642359bd7..ed501efb65 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBox.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBox.tsx @@ -7,29 +7,24 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { useContext, useEffect, useState } from 'react'; -import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, Modal, Tag } from 'antd'; -import { Conversations, Sender, Attachments, Bubble } from '@ant-design/x'; +import React, { useContext, useState } from 'react'; +import { Layout, Card, Divider, Button, Avatar, List, Input, Popover, Empty, Spin, App } from 'antd'; +import { Conversations, Sender, Bubble } from '@ant-design/x'; import type { ConversationsProps } from '@ant-design/x'; -import { - CloseOutlined, - ExpandOutlined, - EditOutlined, - LayoutOutlined, - DeleteOutlined, - BuildOutlined, -} from '@ant-design/icons'; -import { useAPIClient, useRequest, useToken } from '@nocobase/client'; +import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useAPIClient, useToken } from '@nocobase/client'; import { useT } from '../../locale'; -import { ChatBoxContext } from './ChatBoxProvider'; const { Header, Footer, Sider, Content } = Layout; import { avatars } from '../avatars'; -import { AIEmployee, AIEmployeesContext, useAIEmployeesContext } from '../AIEmployeesProvider'; +import { useAIEmployeesContext } from '../AIEmployeesProvider'; import { css } from '@emotion/css'; import { ReactComponent as EmptyIcon } from '../empty-icon.svg'; import { Attachment } from './Attachment'; +import { ChatBoxContext } from './ChatBoxContext'; +import { AIEmployee } from '../types'; export const ChatBox: React.FC = () => { + const { modal, message } = App.useApp(); const api = useAPIClient(); const { send, @@ -46,6 +41,9 @@ export const ChatBox: React.FC = () => { setAttachments, responseLoading, senderRef, + senderValue, + setSenderValue, + clear, } = useContext(ChatBoxContext); const { loading: ConversationsLoading, data: conversationsRes } = conversationsService; const { @@ -54,7 +52,7 @@ export const ChatBox: React.FC = () => { } = useAIEmployeesContext(); const t = useT(); const { token } = useToken(); - const [showConversations, setShowConversations] = useState(true); + const [showConversations, setShowConversations] = useState(false); const aiEmployeesList = [{ username: 'all' } as AIEmployee, ...(aiEmployees || [])]; const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({ key: conversation.sessionId, @@ -66,9 +64,9 @@ export const ChatBox: React.FC = () => { await api.resource('aiConversations').destroy({ filterByTk: sessionId, }); + message.success(t('Deleted successfully')); conversationsService.refresh(); - setCurrentConversation(undefined); - setMessages([]); + clear(); }; const getMessages = async (sessionId: string) => { @@ -132,7 +130,7 @@ export const ChatBox: React.FC = () => { ) : ( { padding: 0; ${highlight} `} - onClick={() => setFilterEmployee(aiEmployee.username)} + onClick={() => { + clear(); + setFilterEmployee(aiEmployee.username); + }} > @@ -237,7 +238,7 @@ export const ChatBox: React.FC = () => { onClick: ({ key }) => { switch (key) { case 'delete': - Modal.confirm({ + modal.confirm({ title: t('Delete this conversation?'), content: t('Are you sure to delete this conversation?'), onOk: () => deleteConversation(conversation.key), @@ -279,11 +280,7 @@ export const ChatBox: React.FC = () => { icon={} type="text" onClick={() => { - setCurrentConversation(undefined); - setMessages([]); - senderRef.current?.focus({ - cursor: 'start', - }); + clear(); }} /> ) : null} @@ -301,6 +298,7 @@ export const ChatBox: React.FC = () => { style={{ margin: '16px 0', overflow: 'auto', + position: 'relative', }} > {messages?.length ? ( @@ -314,10 +312,11 @@ export const ChatBox: React.FC = () => { ) : (
@@ -331,7 +330,11 @@ export const ChatBox: React.FC = () => { }} > { + setSenderValue(value); + }} onSubmit={(content) => send({ sessionId: currentConversation, diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBoxContext.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBoxContext.tsx new file mode 100644 index 0000000000..8fbec476b9 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatBoxContext.tsx @@ -0,0 +1,245 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { AttachmentProps, Conversation, Message, Action, SendOptions, AIEmployee } from '../types'; +import { Avatar, GetProp, GetRef, Button, Alert, Space } from 'antd'; +import type { Sender } from '@ant-design/x'; +import React, { createContext, useContext, useEffect, useState, useRef } from 'react'; +import { Bubble } from '@ant-design/x'; +import { useAPIClient, useRequest } from '@nocobase/client'; +import { AIEmployeesContext } from '../AIEmployeesProvider'; +import { Attachment } from './Attachment'; +import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'; +import { avatars } from '../avatars'; +import { useChatMessages } from './useChatMessages'; + +export const ChatBoxContext = createContext<{ + setOpen: (open: boolean) => void; + open: boolean; + filterEmployee: string; + setFilterEmployee: React.Dispatch>; + conversations: { + loading: boolean; + data?: Conversation[]; + refresh: () => void; + }; + currentConversation: string; + setCurrentConversation: React.Dispatch>; + messages: Message[]; + setMessages: React.Dispatch>; + roles: { [role: string]: any }; + responseLoading: boolean; + attachments: AttachmentProps[]; + setAttachments: React.Dispatch>; + actions: Action[]; + setActions: React.Dispatch>; + senderValue: string; + setSenderValue: React.Dispatch>; + senderRef: React.MutableRefObject>; + clear: () => void; + send(opts: SendOptions): void; +}>({} as any); + +const defaultRoles = { + user: { + placement: 'end', + styles: { + content: { + maxWidth: '400px', + }, + }, + variant: 'borderless', + messageRender: (msg: any) => { + switch (msg.type) { + case 'text': + return ; + default: + return ; + } + }, + }, + error: { + placement: 'start', + variant: 'borderless', + messageRender: (msg: any) => { + return ( + + {msg.content} + ); + }, + }, +}; + +const aiEmployeeRole = (aiEmployee: AIEmployee) => ({ + placement: 'start', + avatar: aiEmployee.avatar ? : null, + typing: { step: 5, interval: 20 }, + style: { + maxWidth: 400, + marginInlineEnd: 48, + }, + styles: { + footer: { + width: '100%', + }, + }, + variant: 'borderless', + messageRender: (msg: any) => { + switch (msg.type) { + case 'greeting': + return ; + case 'text': + return ( + + ; - } - }, -}); - -export const ChatBoxContext = createContext<{ - setOpen: (open: boolean) => void; - open: boolean; - filterEmployee: string; - setFilterEmployee: React.Dispatch>; - conversations: { - loading: boolean; - data?: Conversation[]; - refresh: () => void; - }; - currentConversation: string; - setCurrentConversation: React.Dispatch>; - messages: Message[]; - setMessages: React.Dispatch>; - roles: { [role: string]: any }; - responseLoading: boolean; - attachments: AttachmentProps[]; - setAttachments: React.Dispatch>; - actions: Action[]; - setActions: React.Dispatch>; - senderRef: React.MutableRefObject>; - send(opts: SendOptions): void; -}>({} as any); +import { ChatBoxContext, useChatBoxContext } from './ChatBoxContext'; export const ChatBoxProvider: React.FC<{ children: React.ReactNode; }> = (props) => { - const t = useT(); - const api = useAPIClient(); - const ctx = useContext(CurrentUserContext); - const { aiEmployees } = useContext(AIEmployeesContext); - const [openChatBox, setOpenChatBox] = useState(false); - const [messages, setMessages] = useState([]); - const [filterEmployee, setFilterEmployee] = useState('all'); - const [currentConversation, setCurrentConversation] = useState(); - const [responseLoading, setResponseLoading] = useState(false); - const [attachments, setAttachments] = useState([]); - const [actions, setActions] = useState([]); - const senderRef = useRef>(null); - const [roles, setRoles] = useState>({ - user: { - placement: 'end', - styles: { - content: { - maxWidth: '400px', - }, - }, - variant: 'borderless', - messageRender: (msg: any) => { - switch (msg.type) { - case 'text': - return ; - default: - return ; - } - }, - }, - }); - const conversations = useRequest( - () => - api - .resource('aiConversations') - .list({ - sort: ['-updatedAt'], - ...(filterEmployee !== 'all' - ? { - filter: { - 'aiEmployees.username': filterEmployee, - }, - } - : {}), - }) - .then((res) => res?.data?.data), - { - ready: openChatBox, - refreshDeps: [filterEmployee], - }, - ); - const send = async ({ sessionId, aiEmployee, messages: sendMsgs, greeting }: SendOptions) => { - setRoles((prev) => ({ - ...prev, - [aiEmployee.username]: aiEmployeeRole(aiEmployee), - })); - const msgs: Message[] = []; - if (greeting) { - msgs.push({ - key: uid(), - role: aiEmployee.username, - content: { - type: 'text', - content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }), - }, - }); - setMessages(msgs); - } - if (!sendMsgs.length) { - senderRef.current?.focus(); - return; - } - if (attachments.length) { - msgs.push( - ...attachments.map((attachment) => ({ - key: uid(), - role: 'user', - content: attachment, - })), - ); - setMessages(msgs); - } - msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg }))); - setMessages(msgs); - if (!sessionId) { - const createRes = await api.resource('aiConversations').create({ - values: { - aiEmployees: [aiEmployee], - }, - }); - const conversation = createRes?.data?.data; - if (!conversation) { - return; - } - sessionId = conversation.sessionId; - setCurrentConversation(conversation.sessionId); - conversations.refresh(); - } - setAttachments([]); - setResponseLoading(true); - setMessages((prev) => [ - ...prev, - { - key: uid(), - role: aiEmployee.username, - content: { - type: 'text', - content: '', - }, - loading: true, - }, - ]); - const sendRes = await api.request({ - url: 'aiConversations:sendMessages', - method: 'POST', - headers: { - Accept: 'text/event-stream', - }, - data: { - aiEmployee: aiEmployee.username, - sessionId, - messages: msgs, - }, - responseType: 'stream', - adapter: 'fetch', - }); - if (!sendRes?.data) { - setResponseLoading(false); - return; - } - const reader = sendRes.data.getReader(); - const decoder = new TextDecoder(); - let result = ''; - // eslint-disable-next-line no-constant-condition - while (true) { - let content = ''; - const { done, value } = await reader.read(); - if (done) { - setResponseLoading(false); - break; - } - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n').filter(Boolean); - for (const line of lines) { - const data = JSON.parse(line.replace(/^data: /, '')); - if (data.type === 'content' && data.body) { - content += data.body; - } - } - result += content; - setMessages((prev) => { - const last = prev[prev.length - 1]; - // @ts-ignore - last.content.content = last.content.content + content; - last.loading = false; - return [...prev]; - }); - } - if (actions) { - setMessages((prev) => [ - ...prev, - ...actions.map((action) => ({ - key: uid(), - role: aiEmployee.username, - content: { - type: 'action', - content: action.content, - onClick: () => { - action.onClick(result); - }, - }, - })), - ]); - setActions([]); - } - }; + const currentUserCtx = useContext(CurrentUserContext); + const chatBoxCtx = useChatBoxContext(); + const { open, setOpen } = chatBoxCtx; - useEffect(() => { - if (!aiEmployees) { - return; - } - const roles = aiEmployees.reduce((prev, aiEmployee) => { - return { - ...prev, - [aiEmployee.username]: aiEmployeeRole(aiEmployee), - }; - }, {}); - setRoles((prev) => ({ - ...prev, - ...roles, - })); - }, [aiEmployees]); - - if (!ctx?.data?.data) { + if (!currentUserCtx?.data?.data) { return <>{props.children}; } return ( - + {props.children} - {!openChatBox && ( + {!open && (
} onClick={() => { - setOpenChatBox(true); + setOpen(true); }} shape="square" />
)} - {openChatBox ? : null} + {open ? : null}
); }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/useChatMessages.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/useChatMessages.ts new file mode 100644 index 0000000000..cad08ab168 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/useChatMessages.ts @@ -0,0 +1,216 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useAPIClient } from '@nocobase/client'; +import { useState } from 'react'; +import { Action, AttachmentProps, Message, SendOptions } from '../types'; +import { uid } from '@formily/shared'; +import { useT } from '../../locale'; + +export const useChatMessages = () => { + const t = useT(); + const api = useAPIClient(); + const [messages, setMessages] = useState([]); + const [responseLoading, setResponseLoading] = useState(false); + const [attachments, setAttachments] = useState([]); + const [actions, setActions] = useState([]); + + const clearMessages = () => { + setMessages([]); + setAttachments([]); + setActions([]); + }; + + const addMessage = (message: Message) => { + setMessages((prev) => [...prev, message]); + }; + + const addMessages = (newMessages: Message[]) => { + setMessages((prev) => [...prev, ...newMessages]); + }; + + const updateLastMessage = (updater: (message: Message) => Message) => { + setMessages((prev) => { + const lastIndex = prev.length - 1; + if (lastIndex < 0) { + return prev; + } + + const updated = [...prev]; + updated[lastIndex] = updater(updated[lastIndex]); + return updated; + }); + }; + + const processStreamResponse = async (stream: any) => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ''; + let error = false; + + // eslint-disable-next-line no-constant-condition + while (true) { + let content = ''; + const { done, value } = await reader.read(); + if (done || error) { + setResponseLoading(false); + break; + } + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(Boolean); + + for (const line of lines) { + try { + const data = JSON.parse(line.replace(/^data: /, '')); + if (data.body) { + content += data.body; + } + if (data.type === 'error') { + error = true; + } + } catch (e) { + console.error('Error parsing stream data:', e); + } + } + + result += content; + + // Update the last message with the new content + updateLastMessage((last) => { + // @ts-ignore + last.content.content = last.content.content + content; + last.loading = false; + return last; + }); + } + + if (error) { + updateLastMessage((last) => { + last.role = 'error'; + last.loading = false; + // @ts-ignore + last.content.content = t(result); + return last; + }); + } + + return { result, error }; + }; + + const sendMessages = async ({ + sessionId, + aiEmployee, + messages: sendMsgs, + greeting, + onConversationCreate, + }: SendOptions & { + onConversationCreate?: (sessionId: string) => void; + }) => { + const msgs: Message[] = []; + if (greeting) { + msgs.push({ + key: uid(), + role: aiEmployee.username, + content: { + type: 'greeting', + content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }), + }, + }); + } + if (!sendMsgs.length) { + addMessages(msgs); + return; + } + if (attachments.length) { + msgs.push( + ...attachments.map((attachment) => ({ + key: uid(), + role: 'user', + content: attachment, + })), + ); + } + msgs.push(...sendMsgs.map((msg) => ({ key: uid(), role: 'user', content: msg }))); + addMessages(msgs); + if (!sessionId) { + const createRes = await api.resource('aiConversations').create({ + values: { + aiEmployees: [aiEmployee], + }, + }); + const conversation = createRes?.data?.data; + if (!conversation) { + return; + } + sessionId = conversation.sessionId; + onConversationCreate?.(sessionId); + } + setAttachments([]); + setResponseLoading(true); + + addMessage({ + key: uid(), + role: aiEmployee.username, + content: { + type: 'text', + content: '', + }, + loading: true, + }); + const sendRes = await api.request({ + url: 'aiConversations:sendMessages', + method: 'POST', + headers: { + Accept: 'text/event-stream', + }, + data: { + aiEmployee: aiEmployee.username, + sessionId, + messages: msgs, + }, + responseType: 'stream', + adapter: 'fetch', + }); + if (!sendRes?.data) { + setResponseLoading(false); + return; + } + const { result, error } = await processStreamResponse(sendRes.data); + if (actions.length && !error) { + addMessages( + actions.map((action) => ({ + key: uid(), + role: 'action', + content: { + type: 'action', + icon: action.icon, + content: action.content, + onClick: () => { + action.onClick(result); + }, + }, + })), + ); + setActions([]); + } + }; + + return { + messages, + setMessages, + attachments, + setAttachments, + actions, + setActions, + responseLoading, + sendMessages, + clearMessages, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployeeButton.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployeeButton.tsx index d49e4924af..ca44957e4b 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployeeButton.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployeeButton.tsx @@ -10,8 +10,6 @@ import React, { useContext } from 'react'; import { Avatar, Tag, Popover, Divider, Button } from 'antd'; import { avatars } from '../avatars'; -import { ChatBoxContext } from '../chatbox/ChatBoxProvider'; -import { AIEmployee } from '../AIEmployeesProvider'; import { SortableItem, useBlockContext, @@ -19,26 +17,64 @@ import { useSchemaToolbarRender, useToken, useVariables, + withDynamicSchemaProps, } from '@nocobase/client'; import { useFieldSchema } from '@formily/react'; import { useT } from '../../locale'; import { css } from '@emotion/css'; -import { useForm } from '@formily/react'; +import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider'; +import { ChatBoxContext } from '../chatbox/ChatBoxContext'; +import { AIEmployee } from '../types'; + +async function replaceVariables(template, variables, localVariables = {}) { + const regex = /\{\{\s*(.*?)\s*\}\}/g; + let result = template; + + const matches = [...template.matchAll(regex)]; + + if (matches.length === 0) { + return template; + } + + for (const match of matches) { + const fullMatch = match[0]; + + try { + let value = await variables?.parseVariable(fullMatch, localVariables).then(({ value }) => value); + + if (typeof value !== 'string') { + try { + value = JSON.stringify(value); + } catch (error) { + console.error(error); + } + } + + if (value) { + result = result.replace(fullMatch, value); + } + } catch (error) { + console.error(error); + } + } + + return result; +} export const AIEmployeeButton: React.FC<{ aiEmployee: AIEmployee; - extraInfo?: string; -}> = ({ aiEmployee, extraInfo }) => { + taskDesc?: string; + attachments: string[]; + actions: string[]; +}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, attachments: selectedAttachments, actions: selectedActions }) => { const t = useT(); - const { setOpen, send, setAttachments, setFilterEmployee, setCurrentConversation, setActions } = - useContext(ChatBoxContext); + const { setOpen, send, setAttachments, setFilterEmployee, setActions, clear } = useContext(ChatBoxContext); const { token } = useToken(); const fieldSchema = useFieldSchema(); const { render } = useSchemaToolbarRender(fieldSchema); const variables = useVariables(); const localVariables = useLocalVariables(); - const { name: blockType } = useBlockContext() || {}; - const form = useForm(); + const { attachments, actions } = useAIEmployeeChatContext(); return ( { + clear(); setOpen(true); - setCurrentConversation(undefined); setFilterEmployee(aiEmployee.username); - setAttachments([]); - setActions([]); - const messages = []; - if (blockType === 'form') { - console.log(fieldSchema.parent.parent.toJSON()); - setAttachments((prev) => [ - ...prev, - { - type: 'uiSchema', - content: fieldSchema.parent.parent['x-uid'], - }, - ]); - setActions([ - { - content: 'Fill form', - onClick: (content) => { - try { - const values = content.replace('```json', '').replace('```', ''); - form.setValues(JSON.parse(values)); - } catch (error) { - console.log(error); - } - }, - }, - ]); + if (selectedAttachments && selectedAttachments.length) { + setAttachments((prev) => { + const newAttachments = selectedAttachments.map((name: string) => { + const attachment = attachments[name]; + return { + type: attachment.type, + title: attachment.title, + content: attachment.content, + }; + }); + return [...prev, ...newAttachments]; + }); } - let message = fieldSchema['x-component-props']?.message; + if (selectedActions && selectedActions.length) { + setActions((prev) => { + const newActions = selectedActions.map((name: string) => { + const action = actions[name]; + return { + icon: action.icon, + content: action.title, + onClick: action.action, + }; + }); + return [...prev, ...newActions]; + }); + } + const messages = []; + const message = fieldSchema['x-component-props']?.message; if (message) { - message = await variables - ?.parseVariable(fieldSchema['x-component-props']?.message, localVariables) - .then(({ value }) => value); + const content = await replaceVariables(message.content, variables, localVariables); messages.push({ - type: 'text', - content: message, + type: message.type || 'text', + content, }); } send({ @@ -134,7 +169,7 @@ export const AIEmployeeButton: React.FC<{ {t('Bio')}

{aiEmployee.bio}

- {extraInfo && ( + {taskDesc && ( <> - {t('Extra information')} + {t('Task description')} -

{extraInfo}

+

{taskDesc}

)}
@@ -164,4 +199,4 @@ export const AIEmployeeButton: React.FC<{ {render()} ); -}; +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployees.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployees.tsx new file mode 100644 index 0000000000..9b42322df2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/AIEmployees.tsx @@ -0,0 +1,70 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { SchemaInitializerItem, useSchemaInitializer } from '@nocobase/client'; +import { useAIEmployeesContext } from '../AIEmployeesProvider'; +import { Spin, Avatar } from 'antd'; +import { avatars } from '../avatars'; + +const getAIEmployeesInitializer = (dynamicChatContextHook: string) => ({ + name: 'aiEmployees', + title: 'AI employees', + type: 'subMenu', + useChildren() { + const { + aiEmployees, + service: { loading }, + } = useAIEmployeesContext(); + + return loading + ? [ + { + name: 'spin', + Component: () => , + }, + ] + : aiEmployees.map((aiEmployee) => ({ + name: aiEmployee.username, + Component: () => { + const { insert } = useSchemaInitializer(); + const handleClick = () => { + insert({ + type: 'void', + 'x-decorator': 'AIEmployeeChatProvider', + 'x-use-decorator-props': dynamicChatContextHook, + 'x-component': 'AIEmployeeButton', + 'x-toolbar': 'ActionSchemaToolbar', + 'x-settings': 'aiEmployees:button', + 'x-component-props': { + aiEmployee, + }, + }); + }; + return ( + + } + /> + ); + }, + })); + }, +}); + +export const detailsAIEmployeesInitializer = getAIEmployeesInitializer('useDetailsAIEmployeeChatContext'); +export const formAIEmployeesInitializer = getAIEmployeesInitializer('useFormAIEmployeeChatContext'); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/ConfigureAIEmployees.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/ConfigureAIEmployees.tsx deleted file mode 100644 index 590565d311..0000000000 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/initializer/ConfigureAIEmployees.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import React, { useContext } from 'react'; -import { SchemaInitializer, useAPIClient, useRequest, useSchemaInitializer } from '@nocobase/client'; -import { ReactComponent as DesignIcon } from '../design-icon.svg'; -import { AIEmployee, AIEmployeesContext, useAIEmployeesContext } from '../AIEmployeesProvider'; -import { Spin, Avatar } from 'antd'; -import { avatars } from '../avatars'; - -export const configureAIEmployees = new SchemaInitializer({ - name: 'aiEmployees:configure', - title: '{{t("AI employees")}}', - icon: ( - - - - ), - style: { - marginLeft: 8, - }, - items: [ - { - name: 'ai-employees', - type: 'itemGroup', - useChildren() { - const { - aiEmployees, - service: { loading }, - } = useAIEmployeesContext(); - - return loading - ? [ - { - name: 'spin', - Component: () => , - }, - ] - : aiEmployees.map((aiEmployee) => ({ - name: aiEmployee.username, - title: aiEmployee.nickname, - icon: , - type: 'item', - useComponentProps() { - const { insert } = useSchemaInitializer(); - const handleClick = () => { - insert({ - type: 'void', - 'x-component': 'AIEmployeeButton', - 'x-toolbar': 'ActionSchemaToolbar', - 'x-settings': 'aiEmployees:button', - 'x-component-props': { - aiEmployee, - }, - }); - }; - - return { - onClick: handleClick, - }; - }, - })); - }, - }, - ], -}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/manager/Employees.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/manager/Employees.tsx index f8a25ac258..169469fa45 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/manager/Employees.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/manager/Employees.tsx @@ -8,7 +8,7 @@ */ import React, { createContext, useContext, useMemo } from 'react'; -import { Card, Row, Col, Avatar, Input, Space, Button, Tabs, App, Spin, Empty } from 'antd'; +import { Card, Row, Col, Avatar, Input, Space, Button, Tabs, App, Spin, Empty, Typography } from 'antd'; import { CollectionRecordProvider, SchemaComponent, @@ -311,6 +311,7 @@ const useEditActionProps = () => { export const Employees: React.FC = () => { const t = useT(); + const { message, modal } = App.useApp(); const { token } = useToken(); const api = useAPIClient(); const { data, loading, refresh } = useRequest< @@ -327,6 +328,20 @@ export const Employees: React.FC = () => { .then((res) => res?.data?.data), ); + const del = (username: string) => { + modal.confirm({ + title: t('Delete AI employee'), + content: t('Are you sure to delete this employee?'), + onOk: async () => { + await api.resource('aiEmployees').destroy({ + filterByTk: username, + }); + message.success(t('Deleted successfully')); + refresh(); + }, + }); + }; + return (
{ }, }} />, - , + del(employee.username)} />, ]} > : null} title={employee.nickname} - description={employee.bio} + description={ + + {employee.bio} + + } /> diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/settings/AIEmployeeButton.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/settings/AIEmployeeButton.tsx index c66d802d1f..7b7b60ddef 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/settings/AIEmployeeButton.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/settings/AIEmployeeButton.tsx @@ -19,9 +19,10 @@ import { } from '@nocobase/client'; import { useT } from '../../locale'; import { avatars } from '../avatars'; -import { Card, Avatar } from 'antd'; +import { Card, Avatar, Tooltip } from 'antd'; const { Meta } = Card; import { Schema } from '@formily/react'; +import { useAIEmployeeChatContext } from '../AIEmployeeChatProvider'; export const useAIEmployeeButtonVariableOptions = () => { const collection = useCollection(); @@ -57,6 +58,31 @@ export const aiEmployeeButtonSettings = new SchemaSettings({ const t = useT(); const { dn } = useSchemaSettings(); const aiEmployee = dn.getSchemaAttribute('x-component-props.aiEmployee') || {}; + const { attachments = [], actions = [] } = useAIEmployeeChatContext(); + const attachmentsOptions = useMemo( + () => + Object.entries(attachments).map(([name, item]) => ({ + label: ( + + {item.title} + + ), + value: name, + })), + [attachments], + ); + const actionsOptions = useMemo( + () => + Object.entries(actions).map(([name, item]) => ({ + label: ( + + {item.title} + + ), + value: name, + })), + [actions], + ); return ( ( - + : null} + avatar={aiEmployee.avatar ? : null} title={aiEmployee.nickname} description={aiEmployee.bio} /> ), }, - message: { + taskDesc: { type: 'string', - title: t('Message'), + title: t('Task description'), 'x-decorator': 'FormItem', - 'x-component': 'Variable.RawTextArea', - 'x-component-props': { - scope: '{{ useAIEmployeeButtonVariableOptions }}', - fieldNames: { - value: 'name', - label: 'title', + 'x-component': 'Input.TextArea', + description: t( + 'Displays the AI employee’s assigned tasks on the profile when hovering over the button.', + ), + default: dn.getSchemaAttribute('x-component-props.taskDesc'), + }, + manualMessage: { + type: 'boolean', + 'x-content': t('Requires the user to enter a message manually.'), + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + default: dn.getSchemaAttribute('x-component-props.manualMessage') || false, + }, + message: { + type: 'object', + properties: { + messageType: { + type: 'string', + title: t('Message type'), + 'x-decorator': 'FormItem', + 'x-component': 'Select', + enum: [ + { + label: t('Text'), + value: 'text', + }, + { + label: t('Image'), + value: 'image', + }, + ], + default: 'text', + 'x-component-props': { + placeholder: t('Message type'), + }, + }, + content: { + title: t('Message content'), + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Variable.RawTextArea', + 'x-component-props': { + scope: '{{ useAIEmployeeButtonVariableOptions }}', + changeOnSelect: true, + fieldNames: { + value: 'name', + label: 'title', + }, + }, }, }, default: dn.getSchemaAttribute('x-component-props.message'), + 'x-reactions': { + dependencies: ['.manualMessage'], + fulfill: { + state: { + visible: '{{ !$deps[0] }}', + }, + }, + }, }, - extraInfo: { - type: 'string', - title: t('Extra Information'), + attachments: { + type: 'array', + title: t('Attachments'), + 'x-component': 'Checkbox.Group', 'x-decorator': 'FormItem', - 'x-component': 'Input.TextArea', - default: dn.getSchemaAttribute('x-component-props.extraInfo'), + enum: attachmentsOptions, + default: dn.getSchemaAttribute('x-component-props.attachments'), + 'x-reactions': { + target: 'attachments', + fulfill: { + state: { + visible: '{{$self.value.length}}', + }, + }, + }, + }, + actions: { + type: 'array', + title: t('Actions'), + 'x-component': 'Checkbox.Group', + 'x-decorator': 'FormItem', + enum: actionsOptions, + default: dn.getSchemaAttribute('x-component-props.actions'), + 'x-reactions': { + target: 'actions', + fulfill: { + state: { + visible: '{{$self.value.length}}', + }, + }, + }, }, }, }} title={t('Edit')} - onSubmit={({ message, extraInfo }) => { + onSubmit={({ message, taskDesc, manualMessage, attachments, actions }) => { dn.deepMerge({ 'x-uid': dn.getSchemaAttribute('x-uid'), 'x-component-props': { aiEmployee, message, - extraInfo, + taskDesc, + manualMessage, + attachments, + actions, }, }); }} diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts new file mode 100644 index 0000000000..e1471294a7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts @@ -0,0 +1,50 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { BubbleProps } from '@ant-design/x'; + +export type AIEmployee = { + username: string; + nickname?: string; + avatar?: string; + bio?: string; + greeting?: string; +}; + +export type Conversation = { + sessionId: string; + title: string; + updatedAt: string; +}; + +export type AttachmentType = 'image' | 'uiSchema'; +export type AttachmentProps = { + type: AttachmentType; + title: string; + content: string; + description?: string; +}; + +export type MessageType = 'text' | AttachmentType; +export type Message = BubbleProps & { key?: string | number; role?: string }; +export type Action = { + icon?: React.ReactNode; + content: string; + onClick: (content: string) => void; +}; + +export type SendOptions = { + sessionId?: string; + greeting?: boolean; + aiEmployee?: AIEmployee; + messages: { + type: MessageType; + content: string; + }[]; +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/useBlockChatContext.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/useBlockChatContext.tsx new file mode 100644 index 0000000000..2e959043b5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/useBlockChatContext.tsx @@ -0,0 +1,47 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { useFieldSchema, useForm } from '@formily/react'; +import { AIEmployeeChatContext } from './AIEmployeeChatProvider'; +import { EditOutlined } from '@ant-design/icons'; +import { useT } from '../locale'; + +export const useDetailsAIEmployeeChatContext = () => { + return {}; +}; + +export const useFormAIEmployeeChatContext = (): AIEmployeeChatContext => { + const t = useT(); + const fieldSchema = useFieldSchema(); + const form = useForm(); + return { + attachments: { + formSchema: { + title: t('Current form'), + type: 'uiSchema', + description: 'The JSON schema of the form', + content: fieldSchema.parent.parent['x-uid'], + }, + }, + actions: { + setFormValues: { + title: t('Set form values'), + icon: , + action: (content: string) => { + try { + form.setValues(JSON.parse(content)); + } catch (error) { + console.error(error); + } + }, + }, + }, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx index e887d2356c..c82c1ed246 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx @@ -16,9 +16,14 @@ import { LLMInstruction } from './workflow/nodes/llm'; import { AIEmployeeInstruction } from './workflow/nodes/employee'; import { tval } from '@nocobase/utils/client'; import { namespace } from './locale'; -import { configureAIEmployees } from './ai-employees/initializer/ConfigureAIEmployees'; +import { detailsAIEmployeesInitializer, formAIEmployeesInitializer } from './ai-employees/initializer/AIEmployees'; import { aiEmployeeButtonSettings } from './ai-employees/settings/AIEmployeeButton'; +import { useDetailsAIEmployeeChatContext, useFormAIEmployeeChatContext } from './ai-employees/useBlockChatContext'; const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider'); +const { AIEmployeeChatProvider } = lazy( + () => import('./ai-employees/AIEmployeeChatProvider'), + 'AIEmployeeChatProvider', +); const { Employees } = lazy(() => import('./ai-employees/manager/Employees'), 'Employees'); const { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices'); const { MessagesSettings } = lazy(() => import('./chat-settings/Messages'), 'MessagesSettings'); @@ -39,6 +44,11 @@ export class PluginAIClient extends Plugin { this.app.use(AIEmployeesProvider); this.app.addComponents({ AIEmployeeButton, + AIEmployeeChatProvider, + }); + this.app.addScopes({ + useDetailsAIEmployeeChatContext, + useFormAIEmployeeChatContext, }); this.app.pluginSettingsManager.add('ai', { icon: 'TeamOutlined', @@ -58,7 +68,16 @@ export class PluginAIClient extends Plugin { Component: LLMServices, }); - this.app.schemaInitializerManager.add(configureAIEmployees); + this.app.schemaInitializerManager.addItem( + 'details:configureActions', + 'enableActions.aiEmployees', + detailsAIEmployeesInitializer, + ); + this.app.schemaInitializerManager.addItem( + 'createForm:configureActions', + 'enableActions.aiEmployees', + formAIEmployeesInitializer, + ); this.app.schemaSettingsManager.add(aiEmployeeButtonSettings); this.aiManager.registerLLMProvider('openai', openaiProviderOptions); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/locale.ts b/packages/plugins/@nocobase/plugin-ai/src/client/locale.ts index 5a3ca3dfc3..7943561caa 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/locale.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/locale.ts @@ -13,7 +13,7 @@ import { useApp } from '@nocobase/client'; export const namespace = pkg.name; -export function useT() { +export function useT(): any { const app = useApp(); return (str: string, options?: any) => app.i18n.t(str, { ns: [pkg.name, 'client'], ...options }); } diff --git a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json index 484d50de44..d62cb116b9 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json @@ -22,5 +22,6 @@ "Temperature description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.", "Top P description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.", "Get models list failed, you can enter a model name manually.": "Get models list failed, you can enter a model name manually.", - "Default greeting message": "Hi, I am {{ nickname }}" + "Default greeting message": "Hi, I am {{ nickname }}", + "Chat error warning": "Failed to send message. Please contact the administrator or try again later." } diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts index f9a2a65f70..9688ed96e1 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts @@ -32,5 +32,9 @@ export default defineCollection({ type: 'string', defaultValue: 'text', }, + { + name: 'title', + type: 'string', + }, ], }); diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts index 1f25187b93..7beeb14e69 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts @@ -79,6 +79,7 @@ export default { messageId: row.messageId, role: row.role, content: { + title: row.title, content: row.content, type: row.type, }, @@ -86,19 +87,30 @@ export default { await next(); }, async sendMessages(ctx: Context, next: Next) { + ctx.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); const { sessionId, aiEmployee, messages } = ctx.action.params.values || {}; if (!sessionId) { - ctx.throw(400); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'sessionId is required' })}\n\n`); + ctx.res.end(); + return next(); } const userMessage = messages.find((message: any) => message.role === 'user'); if (!userMessage) { - ctx.throw(400); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'user message is required' })}\n\n`); + ctx.res.end(); + return next(); } const conversation = await ctx.db.getRepository('aiConversations').findOne({ filterByTk: sessionId, }); if (!conversation) { - ctx.throw(400); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'conversation not found' })}\n\n`); + ctx.res.end(); + return next(); } const employee = await ctx.db.getRepository('aiEmployees').findOne({ filter: { @@ -106,11 +118,16 @@ export default { }, }); if (!employee) { - ctx.throw(400); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'AI employee not found' })}\n\n`); + ctx.res.end(); + return next(); } const modelSettings = employee.modelSettings; if (!modelSettings?.llmService) { - ctx.throw(500); + ctx.log.error('llmService not configured'); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`); + ctx.res.end(); + return next(); } const service = await ctx.db.getRepository('llmServices').findOne({ filter: { @@ -118,12 +135,18 @@ export default { }, }); if (!service) { - ctx.throw(500); + ctx.log.error('llmService not found'); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`); + ctx.res.end(); + return next(); } const plugin = ctx.app.pm.get('ai') as PluginAIServer; const providerOptions = plugin.aiManager.llmProviders.get(service.provider); if (!providerOptions) { - ctx.throw(500); + ctx.log.error('llmService provider not found'); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`); + ctx.res.end(); + return next(); } try { @@ -133,17 +156,15 @@ export default { role: message.role, content: message.content.content, type: message.content.type, + title: message.content.title, })), }); } catch (err) { ctx.log.error(err); - ctx.throw(500); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`); + ctx.res.end(); + return next(); } - ctx.set({ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); ctx.status = 200; const userMessages = []; for (const msg of messages) { @@ -177,7 +198,15 @@ export default { messages: msgs, }, }); - const stream = await provider.stream(); + let stream: any; + try { + stream = await provider.stream(); + } catch (err) { + ctx.log.error(err); + ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`); + ctx.res.end(); + return next(); + } let message = ''; for await (const chunk of stream) { if (!chunk.content) { @@ -195,7 +224,7 @@ export default { }, }); ctx.res.end(); - // await next(); + await next(); }, }, };