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

View File

@ -8,7 +8,7 @@
*/
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 { uid } from '@formily/shared';
import { useT } from '../../locale';
@ -29,6 +29,7 @@ interface ChatMessagesContextValue {
) => Promise<void>;
resendMessages: (options: ResendOptions) => void;
cancelRequest: () => void;
callTool: (options: { sessionId: string; messageId: string; aiEmployee: AIEmployee }) => void;
messagesService: any;
lastMessageRef: (node: HTMLElement | null) => void;
}
@ -166,7 +167,6 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
sessionId,
aiEmployee,
messages: sendMsgs,
infoFormValues,
onConversationCreate,
}: SendOptions & {
onConversationCreate?: (sessionId: string) => void;
@ -179,17 +179,6 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
setMessages((prev) => prev.slice(0, -1));
}
if (infoFormValues) {
msgs.push({
key: uid(),
role: aiEmployee.username,
content: {
type: 'info',
content: infoFormValues,
},
});
}
msgs.push(
...sendMsgs.map((msg) => ({
key: uid(),
@ -313,6 +302,31 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
setResponseLoading(false);
}, [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 messagesService = messagesServiceRef.current;
if (messagesService.loading || !messagesService.data?.meta?.hasMore) {
@ -333,6 +347,7 @@ export const ChatMessagesProvider: React.FC<{ children: React.ReactNode }> = ({
sendMessages,
resendMessages,
cancelRequest,
callTool,
messagesService,
lastMessageRef,
}}

View File

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

View File

@ -16,10 +16,11 @@ import { useT } from '../../locale';
import { useChatMessages } from './ChatMessagesProvider';
import { useChatBoxContext } from './ChatBoxContext';
import { useChatConversations } from './ChatConversationsProvider';
import { SchemaComponent } from '@nocobase/client';
import { SchemaComponent, usePlugin } from '@nocobase/client';
import { uid } from '@formily/shared';
import { Markdown } from './Markdown';
import { ToolCard } from './ToolCard';
import PluginAIClient from '../..';
const MessageWrapper = React.forwardRef<
HTMLDivElement,
@ -33,6 +34,29 @@ const MessageWrapper = React.forwardRef<
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<{
msg: any;
}> = ({ msg }) => {
@ -58,18 +82,7 @@ const AIMessageRenderer: React.FC<{
},
}}
variant="borderless"
content={
<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>
}
content={<AITextMessageRenderer msg={msg} />}
footer={
<Space>
<Button

View File

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

View File

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

View File

@ -8,17 +8,18 @@
*/
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 { useT } from '../../locale';
import { SchemaComponent, useAPIClient, useRequest, useToken } from '@nocobase/client';
import { Schema, useField } from '@formily/react';
import { Field } from '@formily/core';
import { Tool } from '../types';
const ToolInfo: React.FC<{
title: string;
description: string;
schema: any;
schema?: any;
}> = ({ title, description, schema }) => {
const t = useT();
const { token } = useToken();
@ -43,6 +44,7 @@ const ToolInfo: React.FC<{
color: token.colorTextSecondary,
fontSize: token.fontSizeSM,
fontWeight: 400,
whiteSpace: 'pre-wrap',
}}
>
{Schema.compile(description, { t })}
@ -60,13 +62,14 @@ const ToolInfo: React.FC<{
</div>
<List
itemLayout="vertical"
dataSource={Object.entries(schema.properties || {})}
dataSource={Object.entries(schema?.properties || {})}
size="small"
renderItem={([name, option]: [
string,
{
name: string;
type: string;
title?: string;
description?: string;
},
]) => {
@ -78,7 +81,7 @@ const ToolInfo: React.FC<{
fontWeight: token.fontWeightStrong,
}}
>
{name}
{option.title || name}
</span>
<span
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 = () => {
const t = useT();
const { token } = useToken();
const field = useField<Field>();
const api = useAPIClient();
const { data, loading } = useRequest<
{
name: string;
title: string;
description: string;
schema: any;
}[]
>(() =>
const { data, loading } = useRequest<Tool[]>(() =>
api
.resource('aiTools')
.list()
.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 =
data?.map((item) => ({
key: item.name,
label: (
<div>
<div>{Schema.compile(item.title, { t })}</div>
<div style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}>
{Schema.compile(item.description, { t })}
</div>
</div>
),
onClick: () => {
const skills = [...(field.value || [])];
skills.push(item.name);
field.value = Array.from(new Set(skills));
},
})) || [];
data?.map((item) => {
const result: any = {
key: item.name,
};
if (item.children) {
result.label = <SkillsListItem {...item} isRoot={true} />;
result.children = item.children.map((child) => {
return {
key: child.name,
label: <SkillsListItem {...child} />,
onClick: () => handleAdd(child.name),
};
});
} else {
result.label = <SkillsListItem {...item} />;
result.onClick = () => handleAdd(item.name);
}
return result;
}) || [];
return (
<>
<div
@ -188,7 +231,17 @@ export const Skills: React.FC = () => {
bordered
dataSource={field.value || []}
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) {
return null;
}
@ -242,15 +295,31 @@ export const Skills: React.FC = () => {
};
export const SkillsSettings: React.FC = () => {
const t = useT();
return (
<SchemaComponent
components={{ Skills }}
schema={{
type: 'void',
properties: {
skills: {
type: 'array',
'x-component': 'Skills',
skillSettings: {
type: 'object',
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 };
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 { useAISelectionContext } from './ai-employees/selector/AISelectorProvider';
import { googleGenAIProviderOptions } from './llm-providers/google-genai';
import { AIEmployeeTrigger } from './workflow/triggers/ai-employee';
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
const { AIEmployeeChatProvider } = lazy(
() => import('./ai-employees/AIEmployeeChatProvider'),
@ -119,6 +120,7 @@ export class PluginAIClient extends Plugin {
});
const workflow = this.app.pm.get('workflow') as PluginWorkflowClient;
workflow.registerTrigger('ai-employee', AIEmployeeTrigger);
workflow.registerInstructionGroup('ai', { label: tval('AI', { ns: namespace }) });
workflow.registerInstruction('llm', LLMInstruction);
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,
},
},
maxCompletionTokens: {
title: tval('Max completion tokens', { ns: namespace }),
maxOutputTokens: {
title: tval('Max output tokens', { ns: namespace }),
description: tval('Max completion tokens description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: -1,
},
presencePenalty: {
title: tval('Presence penalty', { ns: namespace }),

View File

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

View File

@ -14,6 +14,9 @@ export type LLMProviderOptions = {
components: {
ProviderSettingsForm?: 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',
},
{
name: 'skills',
name: 'skillSettings',
type: 'jsonb',
},
{

View File

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

View File

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

View File

@ -19,11 +19,12 @@ import aiTools from './resource/aiTools';
import { AIEmployeesManager } from './ai-employees/ai-employees-manager';
import Snowflake from './snowflake';
import * as aiEmployeeActions from './resource/aiEmployees';
import { z } from 'zod';
import { googleGenAIProviderOptions } from './llm-providers/google-genai';
import { AIEmployeeTrigger } from './workflow/triggers/ai-employee';
import { formFillter, workflowCaller } from './tools';
export class PluginAIServer extends Plugin {
aiManager = new AIManager();
aiManager = new AIManager(this);
aiEmployeesManager = new AIEmployeesManager(this);
snowflake: Snowflake;
@ -42,21 +43,8 @@ export class PluginAIServer extends Plugin {
this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
this.aiManager.registerLLMProvider('deepseek', deepseekProviderOptions);
this.aiManager.registerLLMProvider('google-genai', googleGenAIProviderOptions);
this.aiManager.registerTool('formFiller', {
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."),
}),
});
this.aiManager.registerTool('workflowCaller', {
title: '{{t("Call workflows")}}',
description: '{{t("Developing...")}}',
schema: {},
});
this.aiManager.registerTool('formFiller', formFillter);
this.aiManager.registerTool('workflowCaller', workflowCaller);
this.app.resourceManager.define(aiResource);
this.app.resourceManager.define(aiConversations);
@ -82,6 +70,7 @@ export class PluginAIServer extends Plugin {
}
const workflow = this.app.pm.get('workflow') as PluginWorkflowServer;
workflow.registerTrigger('ai-employee', AIEmployeeTrigger);
workflow.registerInstruction('llm', LLMInstruction);
}

View File

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