-
{Schema.compile(item.title, { t })}
-
- {Schema.compile(item.description, { t })}
-
-
- ),
- 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 = {
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 (
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);
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/MessageRenderer.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/MessageRenderer.tsx
new file mode 100644
index 0000000000..259db4083a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/MessageRenderer.tsx
@@ -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 (
+
+ {typeof content === 'string' && }
+ {msg.tool_calls?.length && !msg.metadata?.autoCallTool && (
+
+ )}
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/ModelSettings.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/ModelSettings.tsx
index 57af27e7cf..30baa6edb4 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/ModelSettings.tsx
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/ModelSettings.tsx
@@ -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 }),
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/index.ts b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/index.ts
index 0720ea32cb..8098dbe04a 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/index.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/llm-providers/google-genai/index.ts
@@ -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,
},
};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/manager/ai-manager.ts b/packages/plugins/@nocobase/plugin-ai/src/client/manager/ai-manager.ts
index 2f6d8d5677..f749377963 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client/manager/ai-manager.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/manager/ai-manager.ts
@@ -14,6 +14,9 @@ export type LLMProviderOptions = {
components: {
ProviderSettingsForm?: ComponentType;
ModelSettingsForm?: ComponentType;
+ MessageRenderer?: ComponentType<{
+ msg: any;
+ }>;
};
};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/Parameters.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/Parameters.tsx
new file mode 100644
index 0000000000..e20337762d
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/Parameters.tsx
@@ -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 (
+
+
+ {name}
+
+
+ {type}
+
+ {required && (
+
+ {t('required')}
+
+ )}
+
+ );
+};
+
+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 (
+
+ }
+ onClick={() => setVisible(true)}
+ >
+ {t('Add parameter')}
+
+ useAddActionProps(field),
+ }}
+ />
+
+ );
+};
+
+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 (
+
+ } onClick={() => setVisible(true)} variant="link" color="default" />
+ useEditActionProps(field, index) }}
+ />
+
+ );
+};
+
+export const ParameterDesc: React.FC = () => {
+ const record = ArrayItems.useRecord();
+ if (!record?.description) {
+ return null;
+ }
+ return (
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/index.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/index.tsx
new file mode 100644
index 0000000000..05e5b75fac
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/client/workflow/triggers/ai-employee/index.tsx
@@ -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,
+ };
+ }) || []
+ );
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-ai/src/collections/ai-employees.ts b/packages/plugins/@nocobase/plugin-ai/src/collections/ai-employees.ts
index 36f3c697cf..331642198c 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/collections/ai-employees.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/collections/ai-employees.ts
@@ -50,7 +50,7 @@ export default {
type: 'jsonb',
},
{
- name: 'skills',
+ name: 'skillSettings',
type: 'jsonb',
},
{
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts
index 4b0abfeb26..e64a66d61b 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-messages.ts
@@ -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',
},
],
});
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-manager.ts b/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-manager.ts
index ecfe336404..0371c7a8f1 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-manager.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/manager/ai-manager.ts
@@ -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,
+ ) => 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) => Promise;
- }
+ | (BaseToolProps & { type?: 'tool' })
| {
title: string;
description: string;
type: 'group';
- action: (toolName: string, params: Record) => Promise;
- getTools: () => Promise<
- Omit &
- {
- toolName: string;
- }[]
- >;
+ getTool(plugin: PluginAIServer, name: string): Promise;
+ getTools(plugin: PluginAIServer): Promise;
};
export class AIManager {
llmProviders = new Map();
tools = new Registry();
+ 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;
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
index 98a97e2f4f..32a57b774a 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
@@ -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);
}
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts
index c75d967c3f..4647c9a630 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts
@@ -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();
+ },
},
};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiTools.ts b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiTools.ts
index 99f7d53023..bff156b9cb 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiTools.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiTools.ts
@@ -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();
},
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/tools/form-fillter.ts b/packages/plugins/@nocobase/plugin-ai/src/server/tools/form-fillter.ts
new file mode 100644
index 0000000000..afd5075993
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/tools/form-fillter.ts
@@ -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."),
+ }),
+};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/tools/index.ts b/packages/plugins/@nocobase/plugin-ai/src/server/tools/index.ts
new file mode 100644
index 0000000000..ecb0d4f0e1
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/tools/index.ts
@@ -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';
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/tools/workflow-caller.ts b/packages/plugins/@nocobase/plugin-ai/src/server/tools/workflow-caller.ts
new file mode 100644
index 0000000000..2bda7f2211
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/tools/workflow-caller.ts
@@ -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 => {
+ const schemaProperties: Record = {};
+ 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) => {
+ 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) => 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) => invoke(plugin, workflow, args),
+ };
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/workflow/triggers/ai-employee/index.ts b/packages/plugins/@nocobase/plugin-ai/src/server/workflow/triggers/ai-employee/index.ts
new file mode 100644
index 0000000000..c4f8aa5f83
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/workflow/triggers/ai-employee/index.ts
@@ -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';
+}