chore: use-context-selector

This commit is contained in:
xilesun 2025-04-10 14:07:42 +08:00
parent c3b03776ae
commit f60cd60c33
18 changed files with 234 additions and 225 deletions

View File

@ -50,17 +50,6 @@ export function createDetailsWithPaginationUISchema(options: {
'x-read-pretty': true,
'x-use-component-props': 'useDetailsWithPaginationProps',
properties: {
[uid()]: {
type: 'void',
'x-initializer': hideActionInitializer ? undefined : 'aiEmployees:configure',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {},
},
[uid()]: {
type: 'void',
'x-initializer': hideActionInitializer ? undefined : 'details:configureActions',

View File

@ -51,17 +51,6 @@ export function createDetailsUISchema(options: {
'x-read-pretty': true,
'x-use-component-props': 'useDetailsProps',
properties: {
[uid()]: {
type: 'void',
'x-initializer': 'aiEmployees:configure',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {},
},
[uid()]: {
type: 'void',
'x-initializer': 'details:configureActions',

View File

@ -60,18 +60,6 @@ export function createCreateFormBlockUISchema(options: CreateFormBlockUISchemaOp
'x-initializer': 'form:configureFields',
properties: {},
},
[uid()]: {
type: 'void',
'x-initializer': 'aiEmployees:configure',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
style: {
marginBottom: 8,
},
},
properties: {},
},
[uid()]: {
type: 'void',
'x-initializer': 'createForm:configureActions',

View File

@ -17,6 +17,7 @@
"@langchain/core": "^0.3.39",
"@langchain/deepseek": "^0.0.1",
"@langchain/openai": "^0.4.3",
"snowflake-id": "^1.1.0"
"snowflake-id": "^1.1.0",
"use-context-selector": "^2.0.0"
}
}

View File

@ -19,13 +19,11 @@ import { Sender } from '@ant-design/x';
import { ProfileCard } from '../ProfileCard';
export const AIEmployeeHeader: React.FC = () => {
const t = useT();
const { token } = useToken();
const {
service: { loading },
aiEmployees,
} = useAIEmployeesContext();
const { switchAIEmployee } = useChatBoxContext();
const switchAIEmployee = useChatBoxContext('switchAIEmployee');
return (
<Sender.Header closable={false}>
<List

View File

@ -7,19 +7,21 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useContext, useState } from 'react';
import React, { useState } from 'react';
import { Layout, Card, Button } from 'antd';
import { CloseOutlined, ExpandOutlined, EditOutlined, LayoutOutlined } from '@ant-design/icons';
import { useToken } from '@nocobase/client';
const { Header, Footer, Sider, Content } = Layout;
import { ChatBoxContext } from './ChatBoxContext';
import { useChatBoxContext } from './ChatBoxContext';
import { Conversations } from './Conversations';
import { Messages } from './Messages';
import { Sender } from './Sender';
import { useAISelectionContext } from '../selector/AISelectorProvider';
export const ChatBox: React.FC = () => {
const { setOpen, startNewConversation, currentEmployee } = useContext(ChatBoxContext);
const setOpen = useChatBoxContext('setOpen');
const startNewConversation = useChatBoxContext('startNewConversation');
const currentEmployee = useChatBoxContext('currentEmployee');
const { token } = useToken();
const [showConversations, setShowConversations] = useState(false);
const { selectable } = useAISelectionContext();

View File

@ -19,7 +19,7 @@ import {
} from '../types';
import { Avatar, GetProp, GetRef, Button, Alert, Space, Popover } from 'antd';
import type { Sender } from '@ant-design/x';
import React, { createContext, useContext, useEffect, useState, useRef, useMemo } from 'react';
import React, { useContext, useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { Bubble } from '@ant-design/x';
import { useAPIClient, useRequest } from '@nocobase/client';
import { AIEmployeesContext } from '../AIEmployeesProvider';
@ -31,8 +31,8 @@ import { uid } from '@formily/shared';
import { useT } from '../../locale';
import { createForm, Form } from '@formily/core';
import { ProfileCard } from '../ProfileCard';
import _ from 'lodash';
import { InfoFormMessage } from './InfoForm';
import { createContext, useContextSelector } from 'use-context-selector';
export const ChatBoxContext = createContext<{
setOpen: (open: boolean) => void;
@ -213,7 +213,8 @@ export const useSetChatBoxContext = () => {
conversations.refresh();
},
};
if (!_.isEmpty(infoForm?.values)) {
const hasInfoFormValues = Object.values(infoForm?.values || []).filter(Boolean).length;
if (hasInfoFormValues) {
sendOptions.infoFormValues = { ...infoForm.values };
}
setSenderValue('');
@ -230,71 +231,77 @@ export const useSetChatBoxContext = () => {
}
};
const switchAIEmployee = (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('');
}
};
const startNewConversation = () => {
setCurrentConversation(undefined);
setCurrentEmployee(null);
setSenderValue('');
infoForm.reset();
setMessages([]);
senderRef.current?.focus();
};
const triggerShortcut = (options: ShortcutOptions) => {
const { aiEmployee, message, infoFormValues, autoSend } = options;
updateRole(aiEmployee);
if (!open) {
setOpen(true);
}
if (currentConversation) {
setCurrentConversation(undefined);
setMessages([]);
}
setCurrentEmployee(aiEmployee);
if (message && message.type === 'text') {
setSenderValue(message.content);
} else {
setSenderValue('');
}
setMessages([
{
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);
setSenderValue('');
infoForm.reset();
setMessages([]);
senderRef.current?.focus();
infoForm.setValues(infoFormValues);
if (autoSend) {
send({
aiEmployee,
messages: [message],
});
}
};
}, [infoForm]);
const triggerShortcut = useCallback(
(options: ShortcutOptions) => {
const { aiEmployee, message, infoFormValues, autoSend } = options;
updateRole(aiEmployee);
if (!open) {
setOpen(true);
}
if (currentConversation) {
setCurrentConversation(undefined);
setMessages([]);
}
setCurrentEmployee(aiEmployee);
if (message && message.type === 'text') {
setSenderValue(message.content);
} else {
setSenderValue('');
}
setMessages([
{
key: uid(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
},
},
]);
senderRef.current?.focus();
infoForm.setValues(infoFormValues);
if (autoSend) {
send({
aiEmployee,
messages: [message],
});
}
},
[open, currentConversation, infoForm],
);
useEffect(() => {
if (!aiEmployees) {
@ -343,6 +350,6 @@ export const useSetChatBoxContext = () => {
};
};
export const useChatBoxContext = () => {
return useContext(ChatBoxContext);
export const useChatBoxContext = (name: string) => {
return useContextSelector(ChatBoxContext, (v) => v[name]);
};

View File

@ -22,13 +22,11 @@ export const Conversations: React.FC = () => {
const api = useAPIClient();
const { modal, message } = App.useApp();
const { token } = useToken();
const {
conversations: conversationsService,
currentConversation,
setCurrentConversation,
setMessages,
startNewConversation,
} = useChatBoxContext();
const conversationsService = useChatBoxContext('conversations');
const currentConversation = useChatBoxContext('currentConversation');
const setCurrentConversation = useChatBoxContext('setCurrentConversation');
const setMessages = useChatBoxContext('setMessages');
const startNewConversation = useChatBoxContext('startNewConversation');
const { loading: ConversationsLoading, data: conversationsRes } = conversationsService;
const conversations: ConversationsProps['items'] = (conversationsRes || []).map((conversation) => ({
key: conversation.sessionId,

View File

@ -14,7 +14,10 @@ import { useChatBoxContext } from './ChatBoxContext';
import { useAISelectionContext } from '../selector/AISelectorProvider';
export const FieldSelector: React.FC = () => {
const { currentEmployee, senderValue, setSenderValue, senderRef } = useChatBoxContext();
const currentEmployee = useChatBoxContext('currentEmployee');
const senderValue = useChatBoxContext('senderValue');
const setSenderValue = useChatBoxContext('setSenderValue');
const senderRef = useChatBoxContext('senderRef');
const { startSelect } = useAISelectionContext();
const handleSelect = () => {

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { AIEmployee } from '../types';
import { SchemaComponent } from '@nocobase/client';
import { BlockSelector } from '../selector/BlockSelector';
@ -87,8 +87,8 @@ export const ReadPrettyInfoForm: React.FC<{
export const InfoFormMessage: React.FC<{
values: any;
}> = ({ values }) => {
const { currentEmployee } = useChatBoxContext();
}> = memo(({ values }) => {
const currentEmployee = useChatBoxContext('currentEmployee');
const t = useT();
const form = useMemo(
() =>
@ -122,4 +122,4 @@ export const InfoFormMessage: React.FC<{
/>
</>
);
};
});

View File

@ -13,7 +13,8 @@ import { useChatBoxContext } from './ChatBoxContext';
import { ReactComponent as EmptyIcon } from '../empty-icon.svg';
export const Messages: React.FC = () => {
const { messages, roles } = useChatBoxContext();
const messages = useChatBoxContext('messages');
const roles = useChatBoxContext('roles');
return (
<>
{messages?.length ? (

View File

@ -19,17 +19,15 @@ import { SenderFooter } from './SenderFooter';
export const Sender: React.FC = memo(() => {
const t = useT();
const {
senderValue,
setSenderValue,
senderPlaceholder,
send,
currentConversation,
currentEmployee,
responseLoading,
showInfoForm,
senderRef,
} = useChatBoxContext();
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 (
<AntSender
value={senderValue}

View File

@ -18,7 +18,7 @@ export const SenderFooter: React.FC<{
}> = ({ components }) => {
const t = useT();
const { SendButton, LoadingButton } = components;
const { responseLoading: loading } = useChatBoxContext();
const { responseLoading: loading } = useChatBoxContext('responseLoading');
return (
<Flex justify="space-between" align="center">

View File

@ -21,7 +21,10 @@ import { uid } from '@formily/shared';
export const SenderHeader: React.FC = () => {
const t = useT();
const { token } = useToken();
const { currentEmployee, startNewConversation, showInfoForm, infoForm: form } = useChatBoxContext();
const currentEmployee = useChatBoxContext('currentEmployee');
const startNewConversation = useChatBoxContext('startNewConversation');
const showInfoForm = useChatBoxContext('showInfoForm');
const form = useChatBoxContext('infoForm');
return currentEmployee ? (
showInfoForm ? (
<Sender.Header

View File

@ -8,12 +8,12 @@
*/
import React, { useContext } from 'react';
import { ChatBoxContext } from './ChatBoxContext';
import { useChatBoxContext } from './ChatBoxContext';
import { avatars } from '../avatars';
import { Avatar } from 'antd';
export const SenderPrefix: React.FC = () => {
const { currentEmployee } = useContext(ChatBoxContext);
const currentEmployee = useChatBoxContext('currentEmployee');
return currentEmployee ? (
<Avatar
src={avatars(currentEmployee.avatar)}

View File

@ -67,7 +67,7 @@ export const AIEmployeeButton: React.FC<{
};
infoForm: any;
}> = withDynamicSchemaProps(({ aiEmployee, taskDesc, message, infoForm: infoFormValues, autoSend }) => {
const { triggerShortcut } = useChatBoxContext();
const triggerShortcut = useChatBoxContext('triggerShortcut');
const fieldSchema = useFieldSchema();
const { render } = useSchemaToolbarRender(fieldSchema);
const variables = useVariables();

View File

@ -204,7 +204,6 @@ it('should convert ui schema to json schema', async () => {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'aiEmployees:configure',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',

View File

@ -127,99 +127,131 @@ export default {
ctx.res.end();
return next();
}
const conversation = await ctx.db.getRepository('aiConversations').findOne({
filterByTk: sessionId,
});
if (!conversation) {
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: {
username: aiEmployee,
},
});
if (!employee) {
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.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: {
name: modelSettings.llmService,
},
});
if (!service) {
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.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 {
await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: messages.map((message: any) => ({
messageId: snowflake.generate(),
role: message.role,
content: message.content,
})),
});
} 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();
}
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);
}
userMessages.push({
role: 'user',
content,
});
}
const msgs = [
{
role: 'system',
content: employee.about,
},
...userMessages,
];
const Provider = providerOptions.provider;
const provider = new Provider({
app: ctx.app,
serviceOptions: service.options,
chatOptions: {
...modelSettings,
messages: msgs,
},
});
let stream: any;
try {
stream = await provider.stream();
const conversation = await ctx.db.getRepository('aiConversations').findOne({
filterByTk: sessionId,
});
if (!conversation) {
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: {
username: aiEmployee,
},
});
if (!employee) {
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.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: {
name: modelSettings.llmService,
},
});
if (!service) {
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.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();
}
const historyMessages = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
sort: ['messageId'],
});
const history = [];
for (const msg of historyMessages) {
let content = msg.content.content;
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,
content,
});
}
try {
await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: messages.map((message: any) => ({
messageId: snowflake.generate(),
role: message.role,
content: message.content,
})),
});
} 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();
}
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);
}
if (!content) {
continue;
}
userMessages.push({
role: 'user',
content,
});
}
const msgs = [
{
role: 'system',
content: employee.about,
},
...history,
...userMessages,
];
console.log(msgs);
const Provider = providerOptions.provider;
const provider = new Provider({
app: ctx.app,
serviceOptions: service.options,
chatOptions: {
...modelSettings,
messages: msgs,
},
});
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();
}
} catch (err) {
ctx.log.error(err);
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: 'Chat error warning' })}\n\n`);
@ -239,6 +271,7 @@ export default {
ctx.res.end();
return next();
}
console.log(message);
await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: {
messageId: snowflake.generate(),