feat: workflow tool

This commit is contained in:
xilesun 2025-05-01 23:16:31 +08:00
parent 6a47a926b6
commit b75368d820
25 changed files with 1090 additions and 204 deletions

View File

@ -8,12 +8,13 @@
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FloatButton, Avatar, Dropdown } from 'antd'; import { FloatButton, Avatar, Dropdown, Popover } from 'antd';
import icon from '../icon.svg'; import icon from '../icon.svg';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useChatBoxContext } from './ChatBoxContext'; import { useChatBoxContext } from './ChatBoxContext';
import { useAIEmployeesContext } from '../AIEmployeesProvider'; import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { avatars } from '../avatars'; import { avatars } from '../avatars';
import { ProfileCard } from '../ProfileCard';
export const ChatButton: React.FC = () => { export const ChatButton: React.FC = () => {
const { aiEmployees } = useAIEmployeesContext(); const { aiEmployees } = useAIEmployeesContext();
@ -36,13 +37,16 @@ export const ChatButton: React.FC = () => {
switchAIEmployee(employee); switchAIEmployee(employee);
}} }}
> >
<Avatar <Popover content={<ProfileCard aiEmployee={employee} />} placement="leftTop">
src={avatars(employee.avatar)} <Avatar
size={28} src={avatars(employee.avatar)}
style={{ size={28}
marginRight: '8px', style={{
}} marginRight: '8px',
/> }}
/>
</Popover>
{employee.nickname} {employee.nickname}
</div> </div>
), ),

View File

@ -8,7 +8,7 @@
*/ */
import { createContext, useCallback, useContext, useRef } from 'react'; import { createContext, useCallback, useContext, useRef } from 'react';
import { Message, ResendOptions, SendOptions } from '../types'; // 假设有这些类型定义 import { AIEmployee, Message, ResendOptions, SendOptions } from '../types'; // 假设有这些类型定义
import React, { useState } from 'react'; import React, { useState } from 'react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { useT } from '../../locale'; import { useT } from '../../locale';
@ -29,6 +29,7 @@ interface ChatMessagesContextValue {
) => Promise<void>; ) => Promise<void>;
resendMessages: (options: ResendOptions) => void; resendMessages: (options: ResendOptions) => void;
cancelRequest: () => void; cancelRequest: () => void;
callTool: (options: { sessionId: string; messageId: string; aiEmployee: AIEmployee }) => void;
messagesService: any; messagesService: any;
lastMessageRef: (node: HTMLElement | null) => void; lastMessageRef: (node: HTMLElement | null) => void;
} }
@ -166,7 +167,6 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
sessionId, sessionId,
aiEmployee, aiEmployee,
messages: sendMsgs, messages: sendMsgs,
infoFormValues,
onConversationCreate, onConversationCreate,
}: SendOptions & { }: SendOptions & {
onConversationCreate?: (sessionId: string) => void; onConversationCreate?: (sessionId: string) => void;
@ -179,17 +179,6 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
setMessages((prev) => prev.slice(0, -1)); setMessages((prev) => prev.slice(0, -1));
} }
if (infoFormValues) {
msgs.push({
key: uid(),
role: aiEmployee.username,
content: {
type: 'info',
content: infoFormValues,
},
});
}
msgs.push( msgs.push(
...sendMsgs.map((msg) => ({ ...sendMsgs.map((msg) => ({
key: uid(), key: uid(),
@ -313,6 +302,31 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
setResponseLoading(false); setResponseLoading(false);
}, [currentConversation]); }, [currentConversation]);
const callTool = useCallback(async ({ sessionId, messageId, aiEmployee }) => {
addMessage({
key: uid(),
role: aiEmployee.username,
content: { type: 'text', content: '' },
loading: true,
});
try {
const sendRes = await api.request({
url: 'aiConversations:callTool',
method: 'POST',
headers: { Accept: 'text/event-stream' },
data: { sessionId, messageId },
responseType: 'stream',
adapter: 'fetch',
});
await processStreamResponse(sendRes.data);
messagesServiceRef.current.run(sessionId);
} catch (err) {
throw err;
}
}, []);
const loadMoreMessages = useCallback(async () => { const loadMoreMessages = useCallback(async () => {
const messagesService = messagesServiceRef.current; const messagesService = messagesServiceRef.current;
if (messagesService.loading || !messagesService.data?.meta?.hasMore) { if (messagesService.loading || !messagesService.data?.meta?.hasMore) {
@ -333,6 +347,7 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
sendMessages, sendMessages,
resendMessages, resendMessages,
cancelRequest, cancelRequest,
callTool,
messagesService, messagesService,
lastMessageRef, lastMessageRef,
}} }}

View File

@ -226,7 +226,8 @@ export const Conversations: React.FC = memo(() => {
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
}, },
], ],
onClick: ({ key }) => { onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
switch (key) { switch (key) {
case 'delete': case 'delete':
modal.confirm({ modal.confirm({

View File

@ -16,10 +16,11 @@ import { useT } from '../../locale';
import { useChatMessages } from './ChatMessagesProvider'; import { useChatMessages } from './ChatMessagesProvider';
import { useChatBoxContext } from './ChatBoxContext'; import { useChatBoxContext } from './ChatBoxContext';
import { useChatConversations } from './ChatConversationsProvider'; import { useChatConversations } from './ChatConversationsProvider';
import { SchemaComponent } from '@nocobase/client'; import { SchemaComponent, usePlugin } from '@nocobase/client';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { ToolCard } from './ToolCard'; import { ToolCard } from './ToolCard';
import PluginAIClient from '../..';
const MessageWrapper = React.forwardRef< const MessageWrapper = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -33,6 +34,29 @@ const MessageWrapper = React.forwardRef<
return props.children; return props.children;
}); });
const AITextMessageRenderer: React.FC<{
msg: any;
}> = ({ msg }) => {
const plugin = usePlugin('ai') as PluginAIClient;
const provider = plugin.aiManager.llmProviders.get(msg.metadata?.provider);
if (!provider?.components?.MessageRenderer) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{typeof msg.content === 'string' && <Markdown markdown={msg.content} />}
{msg.tool_calls?.length && <ToolCard tools={msg.tool_calls} messageId={msg.messageId} />}
</div>
);
}
const M = provider.components.MessageRenderer;
return <M msg={msg} />;
};
const AIMessageRenderer: React.FC<{ const AIMessageRenderer: React.FC<{
msg: any; msg: any;
}> = ({ msg }) => { }> = ({ msg }) => {
@ -58,18 +82,7 @@ const AIMessageRenderer: React.FC<{
}, },
}} }}
variant="borderless" variant="borderless"
content={ content={<AITextMessageRenderer msg={msg} />}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{typeof msg.content === 'string' && <Markdown markdown={msg.content} />}
{msg.tool_calls?.length && <ToolCard tools={msg.tool_calls} messageId={msg.messageId} />}
</div>
}
footer={ footer={
<Space> <Space>
<Button <Button

View File

@ -57,7 +57,7 @@ export const Messages: React.FC = () => {
if (!role) { if (!role) {
return null; return null;
} }
return index === 0 ? ( return index === 0 && msg.content?.type !== 'greeting' ? (
<div key={msg.key} ref={lastMessageRef}> <div key={msg.key} ref={lastMessageRef}>
<Bubble {...role} loading={msg.loading} content={msg.content} /> <Bubble {...role} loading={msg.loading} content={msg.content} />
</div> </div>

View File

@ -18,16 +18,34 @@ import { useAPIClient, useGlobalTheme, usePlugin, useRequest, useToken } from '@
import { useChatConversations } from './ChatConversationsProvider'; import { useChatConversations } from './ChatConversationsProvider';
import { Schema } from '@formily/react'; import { Schema } from '@formily/react';
import PluginAIClient from '../..'; import PluginAIClient from '../..';
import { useChatMessages } from './ChatMessagesProvider';
import { useChatBoxContext } from './ChatBoxContext';
const useDefaultAction = (messageId: string) => {
const currentEmployee = useChatBoxContext('currentEmployee');
const { currentConversation } = useChatConversations();
const { callTool } = useChatMessages();
return {
callAction: () => {
callTool({
sessionId: currentConversation,
messageId,
aiEmployee: currentEmployee,
});
},
};
};
const CallButton: React.FC<{ const CallButton: React.FC<{
messageId: string;
name: string; name: string;
args: any; args: any;
}> = ({ name, args }) => { }> = ({ name, messageId, args }) => {
const t = useT(); const t = useT();
const plugin = usePlugin('ai') as PluginAIClient; const plugin = usePlugin('ai') as PluginAIClient;
const tool = plugin.aiManager.tools.get(name); const tool = plugin.aiManager.tools.get(name);
const useAction = tool.useAction; const useAction = tool?.useAction || useDefaultAction;
const { callAction } = useAction(); const { callAction } = useAction(messageId);
return ( return (
<Button <Button
@ -91,7 +109,7 @@ export const ToolCard: React.FC<{
)} )}
</div> </div>
), ),
extra: <CallButton name={tool.name} args={tool.args} />, extra: <CallButton messageId={messageId} name={tool.name} args={tool.args} />,
children: ( children: (
<ReactMarkdown <ReactMarkdown
components={{ components={{

View File

@ -8,17 +8,18 @@
*/ */
import React from 'react'; import React from 'react';
import { List, Button, Dropdown, Tooltip, Card, Popover, Space } from 'antd'; import { List, Button, Dropdown, Tooltip, Card, Popover, Space, Switch } from 'antd';
import { InfoCircleOutlined, PlusOutlined, QuestionCircleOutlined, DeleteOutlined } from '@ant-design/icons'; import { InfoCircleOutlined, PlusOutlined, QuestionCircleOutlined, DeleteOutlined } from '@ant-design/icons';
import { useT } from '../../locale'; import { useT } from '../../locale';
import { SchemaComponent, useAPIClient, useRequest, useToken } from '@nocobase/client'; import { SchemaComponent, useAPIClient, useRequest, useToken } from '@nocobase/client';
import { Schema, useField } from '@formily/react'; import { Schema, useField } from '@formily/react';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { Tool } from '../types';
const ToolInfo: React.FC<{ const ToolInfo: React.FC<{
title: string; title: string;
description: string; description: string;
schema: any; schema?: any;
}> = ({ title, description, schema }) => { }> = ({ title, description, schema }) => {
const t = useT(); const t = useT();
const { token } = useToken(); const { token } = useToken();
@ -43,6 +44,7 @@ const ToolInfo: React.FC<{
color: token.colorTextSecondary, color: token.colorTextSecondary,
fontSize: token.fontSizeSM, fontSize: token.fontSizeSM,
fontWeight: 400, fontWeight: 400,
whiteSpace: 'pre-wrap',
}} }}
> >
{Schema.compile(description, { t })} {Schema.compile(description, { t })}
@ -60,13 +62,14 @@ const ToolInfo: React.FC<{
</div> </div>
<List <List
itemLayout="vertical" itemLayout="vertical"
dataSource={Object.entries(schema.properties || {})} dataSource={Object.entries(schema?.properties || {})}
size="small" size="small"
renderItem={([name, option]: [ renderItem={([name, option]: [
string, string,
{ {
name: string; name: string;
type: string; type: string;
title?: string;
description?: string; description?: string;
}, },
]) => { ]) => {
@ -78,7 +81,7 @@ const ToolInfo: React.FC<{
fontWeight: token.fontWeightStrong, fontWeight: token.fontWeightStrong,
}} }}
> >
{name} {option.title || name}
</span> </span>
<span <span
style={{ style={{
@ -118,41 +121,81 @@ const ToolInfo: React.FC<{
); );
}; };
export const SkillsListItem: React.FC<{
name: string;
title: string;
description: string;
isRoot?: boolean;
}> = ({ name, title, description, isRoot }) => {
const t = useT();
const { token } = useToken();
const field = useField<Field>();
return (
<div
style={{
minWidth: '150px',
maxWidth: '300px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<div>{Schema.compile(title, { t })}</div>
{!isRoot && (
<div>
<Switch size="small" value={field.value?.includes(name)} disabled={field.value?.includes(name)} />
</div>
)}
</div>
<div style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}>
{Schema.compile(description, { t })}
</div>
</div>
);
};
export const Skills: React.FC = () => { export const Skills: React.FC = () => {
const t = useT(); const t = useT();
const { token } = useToken(); const { token } = useToken();
const field = useField<Field>(); const field = useField<Field>();
const api = useAPIClient(); const api = useAPIClient();
const { data, loading } = useRequest< const { data, loading } = useRequest<Tool[]>(() =>
{
name: string;
title: string;
description: string;
schema: any;
}[]
>(() =>
api api
.resource('aiTools') .resource('aiTools')
.list() .list()
.then((res) => res?.data?.data), .then((res) => res?.data?.data),
); );
const handleAdd = (name: string) => {
const skills = [...(field.value || [])];
skills.push(name);
field.value = Array.from(new Set(skills));
};
const items = const items =
data?.map((item) => ({ data?.map((item) => {
key: item.name, const result: any = {
label: ( key: item.name,
<div> };
<div>{Schema.compile(item.title, { t })}</div> if (item.children) {
<div style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}> result.label = <SkillsListItem {...item} isRoot={true} />;
{Schema.compile(item.description, { t })} result.children = item.children.map((child) => {
</div> return {
</div> key: child.name,
), label: <SkillsListItem {...child} />,
onClick: () => { onClick: () => handleAdd(child.name),
const skills = [...(field.value || [])]; };
skills.push(item.name); });
field.value = Array.from(new Set(skills)); } else {
}, result.label = <SkillsListItem {...item} />;
})) || []; result.onClick = () => handleAdd(item.name);
}
return result;
}) || [];
return ( return (
<> <>
<div <div
@ -188,7 +231,17 @@ export const Skills: React.FC = () => {
bordered bordered
dataSource={field.value || []} dataSource={field.value || []}
renderItem={(item: string) => { renderItem={(item: string) => {
const tool = data?.find((tool) => tool.name === item); const [name] = item.split('.');
const root = data?.find((tool) => tool.name === name);
if (!root) {
return null;
}
let tool: any;
if (root.children) {
tool = root.children.find((tool) => tool.name === item);
} else {
tool = root;
}
if (!tool) { if (!tool) {
return null; return null;
} }
@ -242,15 +295,31 @@ export const Skills: React.FC = () => {
}; };
export const SkillsSettings: React.FC = () => { export const SkillsSettings: React.FC = () => {
const t = useT();
return ( return (
<SchemaComponent <SchemaComponent
components={{ Skills }} components={{ Skills }}
schema={{ schema={{
type: 'void', type: 'void',
properties: { properties: {
skills: { skillSettings: {
type: 'array', type: 'object',
'x-component': 'Skills', properties: {
skills: {
type: 'array',
'x-component': 'Skills',
'x-decorator': 'FormItem',
},
autoCall: {
type: 'boolean',
title: t('Automatically use skills when available'),
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
description: t(
'When auto skill usage is enabled, the AI employee will invoke tools automatically without returning tool parameters to the frontend. If disabled, the tool call parameters will be returned in the conversation for the user to review and trigger manually.',
),
},
},
}, },
}, },
}} }}

View File

@ -84,3 +84,11 @@ export type ShortcutOptions = {
message: { type: MessageType; content: string }; message: { type: MessageType; content: string };
autoSend: boolean; autoSend: boolean;
}; };
export type Tool = {
name: string;
title: string;
description: string;
schema?: any;
children?: Tool[];
};

View File

@ -21,6 +21,7 @@ import { aiEmployeeButtonSettings } from './ai-employees/settings/AIEmployeeButt
import { withAISelectable } from './ai-employees/selector/withAISelectable'; import { withAISelectable } from './ai-employees/selector/withAISelectable';
import { useAISelectionContext } from './ai-employees/selector/AISelectorProvider'; import { useAISelectionContext } from './ai-employees/selector/AISelectorProvider';
import { googleGenAIProviderOptions } from './llm-providers/google-genai'; import { googleGenAIProviderOptions } from './llm-providers/google-genai';
import { AIEmployeeTrigger } from './workflow/triggers/ai-employee';
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider'); const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
const { AIEmployeeChatProvider } = lazy( const { AIEmployeeChatProvider } = lazy(
() => import('./ai-employees/AIEmployeeChatProvider'), () => import('./ai-employees/AIEmployeeChatProvider'),
@ -119,6 +120,7 @@ export class PluginAIClient extends Plugin {
}); });
const workflow = this.app.pm.get('workflow') as PluginWorkflowClient; const workflow = this.app.pm.get('workflow') as PluginWorkflowClient;
workflow.registerTrigger('ai-employee', AIEmployeeTrigger);
workflow.registerInstructionGroup('ai', { label: tval('AI', { ns: namespace }) }); workflow.registerInstructionGroup('ai', { label: tval('AI', { ns: namespace }) });
workflow.registerInstruction('llm', LLMInstruction); workflow.registerInstruction('llm', LLMInstruction);
workflow.registerInstruction('ai-employee', AIEmployeeInstruction); workflow.registerInstruction('ai-employee', AIEmployeeInstruction);

View File

@ -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 React from 'react';
import { Markdown } from '../../ai-employees/chatbox/Markdown';
import { ToolCard } from '../../ai-employees/chatbox/ToolCard';
export const MessageRenderer: React.FC<{
msg: {
messageId: string;
content:
| string
| (
| {
type: 'text';
text: string;
}
| any
)[];
tool_calls?: any[];
metadata: {
autoCallTool?: boolean;
};
};
}> = ({ msg }) => {
let content = msg.content;
if (Array.isArray(content)) {
content = content.find((item) => item.type === 'text')?.text;
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{typeof content === 'string' && <Markdown markdown={content} />}
{msg.tool_calls?.length && !msg.metadata?.autoCallTool && (
<ToolCard tools={msg.tool_calls} messageId={msg.messageId} />
)}
</div>
);
};

View File

@ -50,13 +50,12 @@ const Options: React.FC = () => {
max: 2.0, max: 2.0,
}, },
}, },
maxCompletionTokens: { maxOutputTokens: {
title: tval('Max completion tokens', { ns: namespace }), title: tval('Max output tokens', { ns: namespace }),
description: tval('Max completion tokens description', { ns: namespace }), description: tval('Max completion tokens description', { ns: namespace }),
type: 'number', type: 'number',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'InputNumber', 'x-component': 'InputNumber',
default: -1,
}, },
presencePenalty: { presencePenalty: {
title: tval('Presence penalty', { ns: namespace }), title: tval('Presence penalty', { ns: namespace }),

View File

@ -10,10 +10,12 @@
import { LLMProviderOptions } from '../../manager/ai-manager'; import { LLMProviderOptions } from '../../manager/ai-manager';
import { ModelSettingsForm } from './ModelSettings'; import { ModelSettingsForm } from './ModelSettings';
import { ProviderSettingsForm } from './ProviderSettings'; import { ProviderSettingsForm } from './ProviderSettings';
import { MessageRenderer } from './MessageRenderer';
export const googleGenAIProviderOptions: LLMProviderOptions = { export const googleGenAIProviderOptions: LLMProviderOptions = {
components: { components: {
ProviderSettingsForm, ProviderSettingsForm,
ModelSettingsForm, ModelSettingsForm,
MessageRenderer,
}, },
}; };

View File

@ -14,6 +14,9 @@ export type LLMProviderOptions = {
components: { components: {
ProviderSettingsForm?: ComponentType; ProviderSettingsForm?: ComponentType;
ModelSettingsForm?: ComponentType; ModelSettingsForm?: ComponentType;
MessageRenderer?: ComponentType<{
msg: any;
}>;
}; };
}; };

View File

@ -0,0 +1,234 @@
/**
* 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, { useMemo, useState } from 'react';
import { Button, Space, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { useT } from '../../../locale';
import { useForm } from '@formily/react';
import { Field, createForm } from '@formily/core';
import { ArrayItems } from '@formily/antd-v5';
import { ActionContextProvider, SchemaComponent, useActionContext, useToken } from '@nocobase/client';
import { uid } from '@formily/shared';
const useCancelActionProps = () => {
const { setVisible } = useActionContext();
const form = useForm();
return {
onClick: () => {
setVisible(false);
form.reset();
},
};
};
const useAddActionProps = (field: any) => {
const form = useForm();
const { setVisible } = useActionContext();
return {
onClick: async () => {
await form.submit();
const values = { ...form.values };
field.value = [...field.value, values];
setVisible(false);
form.reset();
},
};
};
const useEditActionProps = (field: any, index: number) => {
const form = useForm();
const { setVisible } = useActionContext();
return {
onClick: async () => {
await form.submit();
const values = { ...form.values };
field.value = field.value.map((item: any, i: number) => {
if (i === index) {
return values;
}
return item;
});
setVisible(false);
form.reset();
},
};
};
const schema = (record?: any) => ({
name: uid(),
type: 'void',
'x-component': 'Action.Modal',
'x-decorator': 'FormV2',
title: '{{t("Add parameter")}}',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
title: '{{t("Parameter name")}}',
'x-validator': (value: string) => {
if (!/^[a-zA-Z_]+$/.test(value)) {
return 'a-z, A-Z, _';
}
return '';
},
required: true,
default: record?.name,
},
// title: {
// type: 'string',
// 'x-decorator': 'FormItem',
// 'x-component': 'Input',
// title: '{{t("Parameter display name")}}',
// default: record?.title,
// },
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
title: '{{t("Parameter type")}}',
required: true,
enum: ['string', 'number', 'boolean', 'enum'],
default: record?.type,
},
description: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
title: '{{t("Parameter description")}}',
default: record?.description,
},
required: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': '{{t("Required")}}',
default: record?.required,
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
'x-use-component-props': 'useSubmitActionProps',
},
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-use-component-props': 'useCancelActionProps',
},
},
},
},
});
export const Parameter: React.FC = () => {
const t = useT();
const record = ArrayItems.useRecord();
const { name, type, required } = record || {};
const { token } = useToken();
return (
<Space size="middle">
<div
style={{
marginLeft: 16,
fontWeight: token.fontWeightStrong,
}}
>
{name}
</div>
<div
style={{
fontSize: token.fontSizeSM,
color: token.colorTextDescription,
}}
>
{type}
</div>
{required && (
<div
style={{
color: token.colorError,
fontSize: token.fontSizeSM,
}}
>
{t('required')}
</div>
)}
</Space>
);
};
export const ParameterAddition: React.FC = () => {
const t = useT();
const form = useForm();
const field = form.query('parameters').take() as Field;
const [visible, setVisible] = useState(false);
return (
<ActionContextProvider value={{ visible, setVisible }}>
<Button
variant="dashed"
color="default"
style={{
width: '100%',
}}
icon={<PlusOutlined />}
onClick={() => setVisible(true)}
>
{t('Add parameter')}
</Button>
<SchemaComponent
schema={schema()}
scope={{
t,
useCancelActionProps,
useSubmitActionProps: () => useAddActionProps(field),
}}
/>
</ActionContextProvider>
);
};
export const EditParameter: React.FC = () => {
const t = useT();
const form = useForm();
const field = form.query('parameters').take() as Field;
const [visible, setVisible] = useState(false);
const index = ArrayItems.useIndex();
const record = ArrayItems.useRecord();
return (
<ActionContextProvider value={{ visible, setVisible }}>
<Button icon={<EditOutlined />} onClick={() => setVisible(true)} variant="link" color="default" />
<SchemaComponent
schema={schema(record)}
scope={{ t, useCancelActionProps, useSubmitActionProps: () => useEditActionProps(field, index) }}
/>
</ActionContextProvider>
);
};
export const ParameterDesc: React.FC = () => {
const record = ArrayItems.useRecord();
if (!record?.description) {
return null;
}
return (
<Tooltip title={record.description}>
<QuestionCircleOutlined />
</Tooltip>
);
};

View File

@ -0,0 +1,120 @@
/**
* 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 { Trigger } from '@nocobase/plugin-workflow/client';
import { tval } from '@nocobase/utils/client';
import { ArrayItems } from '@formily/antd-v5';
import { ParameterAddition, Parameter, EditParameter, ParameterDesc } from './Parameters';
// @ts-ignore
import pkg from '../../../../../package.json';
export class AIEmployeeTrigger extends Trigger {
title = tval('AI employee event', { ns: pkg.name });
description = tval('Triggered by AI employees through tool calling.', { ns: pkg.name });
components = {
ArrayItems,
Parameter,
ParameterAddition,
EditParameter,
ParameterDesc,
};
fieldset = {
// name: {
// type: 'string',
// 'x-decorator': 'FormItem',
// 'x-component': 'Input',
// title: tval('Name', { ns: pkg.name }),
// description: tval('The unique identifier for the tool, used by the LLM to call it.', { ns: pkg.name }),
// required: true,
// },
// description: {
// type: 'string',
// 'x-decorator': 'FormItem',
// 'x-component': 'Input.TextArea',
// title: tval('Description', { ns: pkg.name }),
// description: tval('A short explanation of what the tool does, helping the LLM decide when to use it.', {
// ns: pkg.name,
// }),
// },
parameters: {
type: 'array',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems',
title: tval('Parameters', { ns: pkg.name }),
description: tval('The parameters required by the tool', { ns: pkg.name }),
required: true,
items: {
type: 'object',
'x-decorator': 'ArrayItems.Item',
properties: {
left: {
type: 'void',
'x-component': 'Space',
properties: {
sort: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.SortHandle',
},
parameters: {
type: 'void',
'x-component': 'Parameter',
'x-component-props': {
style: {
width: '90%',
},
},
},
},
},
right: {
type: 'void',
'x-component': 'Space',
properties: {
desc: {
type: 'void',
'x-component': 'ParameterDesc',
},
edit: {
type: 'void',
'x-component': 'EditParameter',
},
remove: {
type: 'void',
'x-component': 'ArrayItems.Remove',
'x-component-props': {
style: {
padding: '0',
},
},
},
},
},
},
},
properties: {
add: {
type: 'void',
'x-component': 'ParameterAddition',
},
},
},
};
useVariables(config, options) {
return (
config.parameters?.map((item: { name: string; displayName: string }) => {
return {
key: item.name,
label: item.name,
value: item.name,
};
}) || []
);
}
}

View File

@ -50,7 +50,7 @@ export default {
type: 'jsonb', type: 'jsonb',
}, },
{ {
name: 'skills', name: 'skillSettings',
type: 'jsonb', type: 'jsonb',
}, },
{ {

View File

@ -19,13 +19,21 @@ export default defineCollection({
type: 'bigInt', type: 'bigInt',
primaryKey: true, primaryKey: true,
}, },
{
name: 'role',
type: 'string',
},
{ {
name: 'content', name: 'content',
type: 'jsonb', type: 'jsonb',
}, },
{ {
name: 'role', name: 'toolCalls',
type: 'string', type: 'jsonb',
},
{
name: 'metadata',
type: 'jsonb',
}, },
], ],
}); });

View File

@ -12,6 +12,7 @@ import { LLMProvider } from '../llm-providers/provider';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import { ZodObject } from 'zod'; import { ZodObject } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema'; import zodToJsonSchema from 'zod-to-json-schema';
import PluginAIServer from '../plugin';
export type LLMProviderOptions = { export type LLMProviderOptions = {
title: string; title: string;
@ -23,31 +24,41 @@ export type LLMProviderOptions = {
}) => LLMProvider; }) => LLMProvider;
}; };
interface BaseToolProps {
title: string;
description: string;
name?: string;
schema?: any;
invoke?: (
plugin: PluginAIServer,
args: Record<string, any>,
) => Promise<{
status: 'success' | 'error';
content: string;
}>;
}
export interface GroupToolChild extends BaseToolProps {
name: string;
schema?: any;
}
export type ToolOptions = export type ToolOptions =
| { | (BaseToolProps & { type?: 'tool' })
title: string;
description: string;
type?: 'tool';
schema: any;
action?: (params: Record<string, any>) => Promise<any>;
}
| { | {
title: string; title: string;
description: string; description: string;
type: 'group'; type: 'group';
action: (toolName: string, params: Record<string, any>) => Promise<any>; getTool(plugin: PluginAIServer, name: string): Promise<GroupToolChild | null>;
getTools: () => Promise< getTools(plugin: PluginAIServer): Promise<GroupToolChild[]>;
Omit<ToolOptions, 'action'> &
{
toolName: string;
}[]
>;
}; };
export class AIManager { export class AIManager {
llmProviders = new Map<string, LLMProviderOptions>(); llmProviders = new Map<string, LLMProviderOptions>();
tools = new Registry<ToolOptions>(); tools = new Registry<ToolOptions>();
constructor(protected plugin: PluginAIServer) {}
registerLLMProvider(name: string, options: LLMProviderOptions) { registerLLMProvider(name: string, options: LLMProviderOptions) {
this.llmProviders.set(name, options); this.llmProviders.set(name, options);
} }
@ -61,32 +72,51 @@ export class AIManager {
this.tools.register(name, options); this.tools.register(name, options);
} }
getTool(name: string, raw = false) { async getTool(name: string, raw = false) {
const tool = this.tools.get(name); const [root, child] = name.split('.');
if (!tool) { const tool = this.tools.get(root);
return null; if (!tool) return null;
}
const { title, description } = tool; const processSchema = (schema: any) => {
const result = { if (!schema) return undefined;
name, return schema instanceof ZodObject && raw ? zodToJsonSchema(schema) : schema;
title,
description,
}; };
if (tool.type !== 'group') {
let schema = tool.schema; if (tool.type === 'group' && child) {
if (schema instanceof ZodObject && raw) { const subTool = await tool.getTool(this.plugin, child);
schema = zodToJsonSchema(schema); if (!subTool) return null;
}
result['schema'] = schema; return {
...subTool,
schema: processSchema(subTool.schema),
};
} }
const result: any = {
name,
title: tool.title,
description: tool.description,
};
if (tool.type === 'group') {
const children = await tool.getTools(this.plugin);
result.children = children.map((child) => ({
...child,
schema: processSchema(child.schema),
}));
} else {
result.invoke = tool.invoke;
result.schema = processSchema(tool.schema);
}
return result; return result;
} }
listTools() { async listTools() {
const tools = this.tools.getKeys(); const tools = this.tools.getKeys();
const result = []; const result = [];
for (const name of tools) { for (const name of tools) {
const tool = this.getTool(name, true); const tool = await this.getTool(name, true);
result.push(tool); result.push(tool);
} }
return result; return result;

View File

@ -19,11 +19,12 @@ import aiTools from './resource/aiTools';
import { AIEmployeesManager } from './ai-employees/ai-employees-manager'; import { AIEmployeesManager } from './ai-employees/ai-employees-manager';
import Snowflake from './snowflake'; import Snowflake from './snowflake';
import * as aiEmployeeActions from './resource/aiEmployees'; import * as aiEmployeeActions from './resource/aiEmployees';
import { z } from 'zod';
import { googleGenAIProviderOptions } from './llm-providers/google-genai'; import { googleGenAIProviderOptions } from './llm-providers/google-genai';
import { AIEmployeeTrigger } from './workflow/triggers/ai-employee';
import { formFillter, workflowCaller } from './tools';
export class PluginAIServer extends Plugin { export class PluginAIServer extends Plugin {
aiManager = new AIManager(); aiManager = new AIManager(this);
aiEmployeesManager = new AIEmployeesManager(this); aiEmployeesManager = new AIEmployeesManager(this);
snowflake: Snowflake; snowflake: Snowflake;
@ -42,21 +43,8 @@ export class PluginAIServer extends Plugin {
this.aiManager.registerLLMProvider('openai', openaiProviderOptions); this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
this.aiManager.registerLLMProvider('deepseek', deepseekProviderOptions); this.aiManager.registerLLMProvider('deepseek', deepseekProviderOptions);
this.aiManager.registerLLMProvider('google-genai', googleGenAIProviderOptions); this.aiManager.registerLLMProvider('google-genai', googleGenAIProviderOptions);
this.aiManager.registerTool('formFiller', { this.aiManager.registerTool('formFiller', formFillter);
title: '{{t("Form filler")}}', this.aiManager.registerTool('workflowCaller', workflowCaller);
description: '{{t("Fill the form with the given content")}}',
schema: z.object({
form: z.string().describe('The UI Schema ID of the target form to be filled.'),
data: z
.record(z.any())
.describe("Structured data matching the form's JSON Schema, to be assigned to form.values."),
}),
});
this.aiManager.registerTool('workflowCaller', {
title: '{{t("Call workflows")}}',
description: '{{t("Developing...")}}',
schema: {},
});
this.app.resourceManager.define(aiResource); this.app.resourceManager.define(aiResource);
this.app.resourceManager.define(aiConversations); this.app.resourceManager.define(aiConversations);
@ -82,6 +70,7 @@ export class PluginAIServer extends Plugin {
} }
const workflow = this.app.pm.get('workflow') as PluginWorkflowServer; const workflow = this.app.pm.get('workflow') as PluginWorkflowServer;
workflow.registerTrigger('ai-employee', AIEmployeeTrigger);
workflow.registerInstruction('llm', LLMInstruction); workflow.registerInstruction('llm', LLMInstruction);
} }

View File

@ -11,6 +11,7 @@ import actions, { Context, Next } from '@nocobase/actions';
import PluginAIServer from '../plugin'; import PluginAIServer from '../plugin';
import { Model } from '@nocobase/database'; import { Model } from '@nocobase/database';
import { concat } from '@langchain/core/utils/stream'; import { concat } from '@langchain/core/utils/stream';
import { LLMProvider } from '../../../server';
async function parseUISchema(ctx: Context, content: string) { async function parseUISchema(ctx: Context, content: string) {
const regex = /\{\{\$nUISchema\.([^}]+)\}\}/g; const regex = /\{\{\$nUISchema\.([^}]+)\}\}/g;
@ -40,23 +41,38 @@ async function formatMessages(ctx: Context, messages: any[]) {
for (const msg of messages) { for (const msg of messages) {
let content = msg.content.content; let content = msg.content.content;
if (typeof content !== 'string') { if (typeof content === 'string') {
continue; content = await parseUISchema(ctx, content);
} }
content = await parseUISchema(ctx, content);
if (!content) { if (!content) {
continue; continue;
} }
if (msg.role === 'user') {
formattedMessages.push({
role: 'user',
content,
});
continue;
}
if (msg.role === 'tool') {
formattedMessages.push({
role: 'tool',
content,
tool_call_id: msg.toolCalls?.id,
});
continue;
}
formattedMessages.push({ formattedMessages.push({
role: msg.role === 'user' ? 'user' : 'assistant', role: 'assistant',
content, content,
tool_calls: msg.toolCalls,
}); });
} }
return formattedMessages; return formattedMessages;
} }
async function prepareChatStream(ctx: Context, sessionId: string, employee: Model, messages: any[]) { async function getLLMService(ctx: Context, employee: Model, messages: any[]) {
const plugin = ctx.app.pm.get('ai') as PluginAIServer; const plugin = ctx.app.pm.get('ai') as PluginAIServer;
const modelSettings = employee.modelSettings; const modelSettings = employee.modelSettings;
@ -80,10 +96,10 @@ async function prepareChatStream(ctx: Context, sessionId: string, employee: Mode
} }
const tools = []; const tools = [];
const skills = employee.skills; const skills = employee.skillSettings?.skills || [];
if (skills?.length) { if (skills?.length) {
for (const skill of skills) { for (const skill of skills) {
const tool = plugin.aiManager.getTool(skill); const tool = await plugin.aiManager.getTool(skill);
if (tool) { if (tool) {
tools.push(tool); tools.push(tool);
} }
@ -100,6 +116,11 @@ async function prepareChatStream(ctx: Context, sessionId: string, employee: Mode
}, },
}); });
return { provider, model: modelSettings.model, service };
}
async function prepareChatStream(ctx: Context, sessionId: string, provider: LLMProvider) {
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
@ -117,18 +138,19 @@ async function processChatStream(
stream: any, stream: any,
sessionId: string, sessionId: string,
options: { options: {
aiEmployeeUsername: string; aiEmployee: Model;
signal: AbortSignal; signal: AbortSignal;
messageId?: string; messageId?: string;
model: string;
provider: string;
}, },
) { ) {
const plugin = ctx.app.pm.get('ai') as PluginAIServer; const plugin = ctx.app.pm.get('ai') as PluginAIServer;
const { aiEmployeeUsername, signal, messageId } = options; const { aiEmployee, signal, messageId, model, provider } = options;
let gathered: any; let gathered: any;
try { try {
for await (const chunk of stream) { for await (const chunk of stream) {
console.log(chunk);
gathered = gathered !== undefined ? concat(gathered, chunk) : chunk; gathered = gathered !== undefined ? concat(gathered, chunk) : chunk;
if (!chunk.content) { if (!chunk.content) {
continue; continue;
@ -148,18 +170,25 @@ async function processChatStream(
return; return;
} }
if (message) { if (message) {
const content = { const values = {
content: message, content: {
type: 'text', type: 'text',
content: message,
},
metadata: {
model,
provider,
autoCallTool: aiEmployee.skillSettings?.autoCall,
},
}; };
if (signal.aborted) { if (signal.aborted) {
content['interrupted'] = true; values.metadata['interrupted'] = true;
} }
if (gathered?.tool_calls?.length) { if (gathered?.tool_calls?.length) {
content['tool_calls'] = gathered.tool_calls; values['toolCalls'] = gathered.tool_calls;
} }
if (gathered?.usage_metadata) { if (gathered?.usage_metadata) {
content['usage_metadata'] = gathered.usage_metadata; values.metadata['usage_metadata'] = gathered.usage_metadata;
} }
if (messageId) { if (messageId) {
await ctx.db.sequelize.transaction(async (transaction) => { await ctx.db.sequelize.transaction(async (transaction) => {
@ -168,9 +197,7 @@ async function processChatStream(
sessionId, sessionId,
messageId, messageId,
}, },
values: { values,
content,
},
transaction, transaction,
}); });
await ctx.db.getRepository('aiMessages').destroy({ await ctx.db.getRepository('aiMessages').destroy({
@ -187,17 +214,21 @@ async function processChatStream(
await ctx.db.getRepository('aiConversations.messages', sessionId).create({ await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: { values: {
messageId: plugin.snowflake.generate(), messageId: plugin.snowflake.generate(),
role: aiEmployeeUsername, role: aiEmployee.username,
content, ...values,
}, },
}); });
} }
} }
if (gathered?.tool_calls?.length && aiEmployee.skillSettings?.autoCall) {
await callTool(ctx, gathered.tool_calls[0], sessionId, aiEmployee);
}
ctx.res.end(); ctx.res.end();
} }
async function getConversationHistory(ctx: Context, sessionId: string, employee: Model, messageIdFilter?: string) { async function getConversationHistory(ctx: Context, sessionId: string, messageIdFilter?: string) {
const historyMessages = await ctx.db.getRepository('aiConversations.messages', sessionId).find({ const historyMessages = await ctx.db.getRepository('aiConversations.messages', sessionId).find({
sort: ['messageId'], sort: ['messageId'],
...(messageIdFilter ...(messageIdFilter
@ -214,6 +245,86 @@ async function getConversationHistory(ctx: Context, sessionId: string, employee:
return await formatMessages(ctx, historyMessages); return await formatMessages(ctx, historyMessages);
} }
async function getHistoryMessages(ctx: Context, sessionId: string, aiEmployee: Model, messageId?: string) {
const history = await getConversationHistory(ctx, sessionId, messageId);
const userConfig = await ctx.db.getRepository('usersAiEmployees').findOne({
filter: {
userId: ctx.auth?.user.id,
aiEmployee: aiEmployee.username,
},
});
const historyMessages = [
{
role: 'system',
content: aiEmployee.about,
},
...(userConfig?.prompt ? [{ role: 'user', content: userConfig.prompt }] : []),
...history,
];
return historyMessages;
}
async function callTool(
ctx: Context,
toolCall: {
id: string;
name: string;
args: any;
},
sessionId: string,
aiEmployee: Model,
) {
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
try {
const tool = await plugin.aiManager.getTool(toolCall.name);
if (!tool) {
sendErrorResponse(ctx, 'Tool not found');
}
const result = await tool.invoke(plugin, toolCall.args);
if (result.status === 'error') {
sendErrorResponse(ctx, result.content);
}
const historyMessages = await getHistoryMessages(ctx, sessionId, aiEmployee);
const formattedMessages = [
...historyMessages,
{
role: 'tool',
name: toolCall.name,
content: result.content,
tool_call_id: toolCall.id,
},
];
const { provider, model, service } = await getLLMService(ctx, aiEmployee, formattedMessages);
await ctx.db.getRepository('aiConversations.messages', sessionId).create({
values: {
messageId: plugin.snowflake.generate(),
role: 'tool',
content: {
type: 'text',
content: result.content,
},
toolCalls: toolCall,
metadata: {
model,
provider: service.provider,
autoCallTool: aiEmployee.skillSettings?.autoCall,
},
},
});
const { stream, signal } = await prepareChatStream(ctx, sessionId, provider);
await processChatStream(ctx, stream, sessionId, {
aiEmployee,
signal,
model,
provider: service.provider,
});
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, 'Tool call error');
}
}
function setupSSEHeaders(ctx: Context) { function setupSSEHeaders(ctx: Context) {
ctx.set({ ctx.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -311,18 +422,20 @@ export default {
const pageSize = 10; const pageSize = 10;
const messageRepository = ctx.db.getRepository('aiConversations.messages', sessionId); const messageRepository = ctx.db.getRepository('aiConversations.messages', sessionId);
const filter = {
role: {
$notIn: ['tool'],
},
};
if (cursor) {
filter['messageId'] = {
$lt: cursor,
};
}
const rows = await messageRepository.find({ const rows = await messageRepository.find({
sort: ['-messageId'], sort: ['-messageId'],
limit: pageSize + 1, limit: pageSize + 1,
...(cursor filter,
? {
filter: {
messageId: {
$lt: cursor,
},
},
}
: {}),
}); });
const hasMore = rows.length > pageSize; const hasMore = rows.length > pageSize;
@ -330,14 +443,21 @@ export default {
const newCursor = data.length ? data[data.length - 1].messageId : null; const newCursor = data.length ? data[data.length - 1].messageId : null;
ctx.body = { ctx.body = {
rows: data.map((row: Model) => ({ rows: data.map((row: Model) => {
key: row.messageId, const content = {
content: {
...row.content, ...row.content,
messageId: row.messageId, messageId: row.messageId,
}, metadata: row.metadata,
role: row.role, };
})), if (!row.metadata?.autoCallTool && row.toolCalls) {
content.tool_calls = row.toolCalls;
}
return {
key: row.messageId,
content,
role: row.role,
};
}),
hasMore, hasMore,
cursor: newCursor, cursor: newCursor,
}; };
@ -409,28 +529,17 @@ export default {
return next(); return next();
} }
const history = await getConversationHistory(ctx, sessionId, employee);
const userMessages = await formatMessages(ctx, messages); const userMessages = await formatMessages(ctx, messages);
const userConfig = await ctx.db.getRepository('usersAiEmployees').findOne({ const historyMessages = await getHistoryMessages(ctx, sessionId, employee);
filter: { const formattedMessages = [...historyMessages, ...userMessages];
userId: ctx.auth?.user.id,
aiEmployee,
},
});
const formattedMessages = [
{
role: 'system',
content: employee.about,
},
...(userConfig?.prompt ? [{ role: 'user', content: userConfig.prompt }] : []),
...history,
...userMessages,
];
const { stream, signal } = await prepareChatStream(ctx, sessionId, employee, formattedMessages); const { provider, model, service } = await getLLMService(ctx, employee, formattedMessages);
const { stream, signal } = await prepareChatStream(ctx, sessionId, provider);
await processChatStream(ctx, stream, sessionId, { await processChatStream(ctx, stream, sessionId, {
aiEmployeeUsername: aiEmployee, aiEmployee,
signal, signal,
model,
provider: service.provider,
}); });
} catch (err) { } catch (err) {
ctx.log.error(err); ctx.log.error(err);
@ -483,27 +592,15 @@ export default {
} }
const employee = conversation.aiEmployee; const employee = conversation.aiEmployee;
const history = await getConversationHistory(ctx, sessionId, employee, messageId); const historyMessages = await getHistoryMessages(ctx, sessionId, employee, messageId);
const userConfig = await ctx.db.getRepository('usersAiEmployees').findOne({ const { provider, model, service } = await getLLMService(ctx, employee, historyMessages);
filter: { const { stream, signal } = await prepareChatStream(ctx, sessionId, provider);
userId: ctx.auth?.user.id,
aiEmployee: employee.username,
},
});
const formattedMessages = [
{
role: 'system',
content: employee.about,
},
...(userConfig?.prompt ? [{ role: 'user', content: userConfig.prompt }] : []),
...history,
];
const { stream, signal } = await prepareChatStream(ctx, sessionId, employee, formattedMessages);
await processChatStream(ctx, stream, sessionId, { await processChatStream(ctx, stream, sessionId, {
aiEmployeeUsername: employee.username, aiEmployee: employee,
signal, signal,
messageId, messageId,
model,
provider: service.provider,
}); });
} catch (err) { } catch (err) {
ctx.log.error(err); ctx.log.error(err);
@ -533,17 +630,57 @@ export default {
messageId, messageId,
}, },
}); });
const tools = message?.content?.tool_calls || []; const tools = message?.toolCalls || [];
const toolNames = tools.map((tool: any) => tool.name); const toolNames = tools.map((tool: any) => tool.name);
const result = {}; const result = {};
for (const toolName of toolNames) { for (const toolName of toolNames) {
const tool = plugin.aiManager.getTool(toolName); const tool = await plugin.aiManager.getTool(toolName);
if (tool) { if (tool) {
result[toolName] = tool; result[toolName] = {
name: tool.name,
title: tool.title,
description: tool.description,
};
} }
} }
ctx.body = result; ctx.body = result;
await next(); await next();
}, },
async callTool(ctx: Context, next: Next) {
setupSSEHeaders(ctx);
const { sessionId, messageId } = ctx.action.params.values || {};
if (!sessionId || !messageId) {
sendErrorResponse(ctx, 'sessionId and messageId are required');
}
try {
const conversation = await ctx.db.getRepository('aiConversations').findOne({
filter: {
sessionId,
userId: ctx.auth?.user.id,
},
appends: ['aiEmployee'],
});
if (!conversation) {
sendErrorResponse(ctx, 'conversation not found');
}
const employee = conversation.aiEmployee;
const message = await ctx.db.getRepository('aiConversations.messages', sessionId).findOne({
filter: {
messageId,
},
});
const tools = message.toolCalls;
if (!tools?.length) {
sendErrorResponse(ctx, 'No tool calls found');
}
await callTool(ctx, tools[0], sessionId, employee);
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, 'Tool call error');
}
await next();
},
}, },
}; };

View File

@ -15,7 +15,7 @@ export const aiTools: ResourceOptions = {
actions: { actions: {
list: async (ctx, next) => { list: async (ctx, next) => {
const plugin = ctx.app.pm.get('ai') as PluginAIServer; const plugin = ctx.app.pm.get('ai') as PluginAIServer;
const tools = plugin.aiManager.listTools(); const tools = await plugin.aiManager.listTools();
ctx.body = tools; ctx.body = tools;
await next(); await next();
}, },

View File

@ -0,0 +1,20 @@
/**
* 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 { z } from 'zod';
import { ToolOptions } from '../manager/ai-manager';
export const formFillter: ToolOptions = {
title: '{{t("Form filler")}}',
description: '{{t("Fill the form with the given content")}}',
schema: z.object({
form: z.string().describe('The UI Schema ID of the target form to be filled.'),
data: z.record(z.any()).describe("Structured data matching the form's JSON Schema, to be assigned to form.values."),
}),
};

View File

@ -0,0 +1,11 @@
/**
* 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.
*/
export * from './form-fillter';
export * from './workflow-caller';

View File

@ -0,0 +1,139 @@
/**
* 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 { ToolOptions } from '../manager/ai-manager';
import { z } from 'zod';
import PluginAIServer from '../plugin';
import PluginWorkflowServer, { Processor, EXECUTION_STATUS } from '@nocobase/plugin-workflow';
interface ParameterConfig {
name: string;
displayName?: string;
description?: string;
type: 'string' | 'number' | 'enum' | 'boolean';
options?: Array<{ value: string | number; label: string }>;
required?: boolean;
}
interface ToolConfig {
name: string;
description?: string;
parameters?: ParameterConfig[];
}
interface Workflow {
key: string;
title: string;
description?: string;
config: ToolConfig;
}
const buildSchema = (config: ToolConfig): z.ZodObject<any> => {
const schemaProperties: Record<string, z.ZodTypeAny> = {};
if (config.parameters?.length) {
config.parameters.forEach((item) => {
let fieldSchema: z.ZodTypeAny;
switch (item.type) {
case 'string':
fieldSchema = z.string();
break;
case 'number':
fieldSchema = z.number();
break;
case 'boolean':
fieldSchema = z.boolean();
break;
case 'enum':
if (item.options && item.options.length > 0) {
const enumValues = item.options.map((option) => option.value);
if (typeof enumValues[0] === 'number') {
const values = enumValues.map(String) as [string, ...string[]];
fieldSchema = z.enum(values).transform((v) => Number(v));
} else {
fieldSchema = z.enum(enumValues as [string, ...string[]]);
}
} else {
fieldSchema = z.string();
}
break;
default:
fieldSchema = z.any();
}
if (item.description) {
fieldSchema = fieldSchema.describe(item.description);
}
if (!item.required) {
fieldSchema = fieldSchema.optional();
}
schemaProperties[item.name] = fieldSchema;
});
}
const schema = z.object(schemaProperties);
return schema.describe(config.description || '');
};
const invoke = async (plugin: PluginAIServer, workflow: Workflow, args: Record<string, any>) => {
const workflowPlugin = plugin.app.pm.get('workflow') as PluginWorkflowServer;
const processor = (await workflowPlugin.trigger(workflow as any, {
...args,
})) as Processor;
if (!processor.lastSavedJob) {
return { status: 'error' as const, content: 'No content' };
}
if (processor.execution.status !== EXECUTION_STATUS.RESOLVED) {
return { status: 'error' as const, content: 'Workflow execution exceptions' };
}
const lastJobResult = processor.lastSavedJob.result;
return {
status: 'success' as const,
content: JSON.stringify(lastJobResult),
};
};
export const workflowCaller: ToolOptions = {
type: 'group',
title: '{{t("Workflow caller")}}',
description: '{{t("Use workflow as a tool")}}',
getTools: async (plugin) => {
const workflowPlugin = plugin.app.pm.get('workflow') as PluginWorkflowServer;
const workflows = Array.from(workflowPlugin.enabledCache.values()).filter((item) => item.type === 'ai-employee');
return workflows.map((workflow: Workflow) => {
const config = workflow.config;
return {
name: `workflowCaller.${workflow.key}`,
title: workflow.title,
description: workflow.description,
schema: buildSchema(config),
invoke: async (plugin: PluginAIServer, args: Record<string, any>) => invoke(plugin, workflow, args),
};
});
},
getTool: async (plugin, name) => {
const workflowPlugin = plugin.app.pm.get('workflow') as PluginWorkflowServer;
const workflow = Array.from(workflowPlugin.enabledCache.values()).find(
(item) => item.type === 'ai-employee' && item.key === name,
);
if (!workflow) {
return null;
}
const config = workflow.config;
return {
name: `workflowCaller.${workflow.key}`,
title: workflow.title,
description: workflow.description,
schema: buildSchema(config),
invoke: async (plugin: PluginAIServer, args: Record<string, any>) => invoke(plugin, workflow, args),
};
},
};

View File

@ -0,0 +1,14 @@
/**
* 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 { Trigger } from '@nocobase/plugin-workflow';
export class AIEmployeeTrigger extends Trigger {
static TYPE = 'ai-employee';
}