feat: ai integration (#6283)

* feat: ai

* fix: build

* fix: plugin

* fix: bug

* fix: version

* chore: add debug info

* chore: allows to enter a model name manually

* chore: update

* chore: optimize

* fix: bug

* fix: version

* fix: build

* fix: test
This commit is contained in:
YANG QIA 2025-03-04 16:54:11 +08:00 committed by GitHub
parent 2bad77ac83
commit 4720244eb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2841 additions and 0 deletions

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-ai

View File

@ -0,0 +1,165 @@
import { defineConfig } from '@nocobase/build';
import fs from 'fs-extra';
import path from 'path';
async function generateIndexFiles(packageJsonPath: string) {
// Read and parse package.json
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
const exports = packageJson.exports;
// Collect all export paths excluding package.json
const exportPaths = Object.entries(exports)
.filter(([key]) => key !== './package.json')
.map(([key, value]: [string, any]) => {
// Remove './' prefix from the key
const normalizedKey = key.startsWith('./') ? key.slice(2) : key;
// Get the paths for different formats
const paths = {
js: typeof value === 'object' ? value.import?.replace(/^\.\//, '') : value?.replace(/^\.\//, ''),
dts: typeof value === 'object' ? value.types?.import?.replace(/^\.\//, '') : null,
};
return { key: normalizedKey, paths };
});
// Generate JavaScript index content
const jsContent = `// This file is auto-generated from package.json exports
// Do not edit this file manually
${exportPaths.map(({ key, paths }) => `export * from './${paths.js.replace(/\.js$/, '')}';`).join('\n')}
`;
// Generate CommonJS index content
const cjsContent = `// This file is auto-generated from package.json exports
// Do not edit this file manually
${exportPaths.map(({ key, paths }) => `module.exports = require('../${paths.js.replace(/\.js$/, '')}');`).join('\n')}
`;
// Generate TypeScript declaration content
const dtsContent = `// This file is auto-generated from package.json exports
// Do not edit this file manually
${exportPaths
.map(({ key, paths }) => {
const dtsPath = paths.dts?.replace(/\.d\.ts$/, '') || paths.js.replace(/\.js$/, '');
return `export * from './${dtsPath}';`;
})
.join('\n')}
`;
// Generate CommonJS declaration content
const ctsContent = `// This file is auto-generated from package.json exports
// Do not edit this file manually
${exportPaths
.map(({ key, paths }) => {
const dtsPath = paths.dts?.replace(/\.d\.ts$/, '') || paths.js.replace(/\.js$/, '');
return `export * from './${dtsPath}';`;
})
.join('\n')}
`;
// Generate package.json exports for index files
const indexExports = {
'.': {
types: {
import: './index.d.ts',
require: './index.d.cts',
default: './index.d.ts',
},
import: './index.js',
require: './index.cjs',
},
...exports, // Keep existing exports
};
return {
js: jsContent,
cjs: cjsContent,
dts: dtsContent,
cts: ctsContent,
exports: indexExports,
};
}
async function writeIndexFiles(pkgPath: string) {
try {
const contents = await generateIndexFiles(pkgPath);
const outputDir = path.dirname(pkgPath);
// Write index.js
await fs.writeFile(path.join(outputDir, 'index.js'), contents.js);
await fs.writeFile(path.join(outputDir, 'dist', 'index.js'), contents.js);
console.log('Generated index.js');
// Write index.js
await fs.writeFile(path.join(outputDir, 'index.cjs'), contents.cjs);
await fs.writeFile(path.join(outputDir, 'dist', 'index.cjs'), contents.cjs);
console.log('Generated index.cjs');
// Write index.d.ts
await fs.writeFile(path.join(outputDir, 'index.d.ts'), contents.dts);
await fs.writeFile(path.join(outputDir, 'dist', 'index.d.ts'), contents.dts);
console.log('Generated index.d.ts');
// Write index.d.cts
await fs.writeFile(path.join(outputDir, 'index.d.cts'), contents.cts);
console.log('Generated index.d.cts');
// Update package.json with new exports
const packageJson = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
packageJson.exports = contents.exports;
await fs.writeFile(pkgPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log('Updated package.json exports');
} catch (error) {
console.error('Error generating index files:', error);
}
}
export default defineConfig({
beforeBuild: async (log) => {
await fs.promises.rm(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
const pkgPath = path.resolve(process.cwd(), 'node_modules/@langchain/core/package.json');
await writeIndexFiles(pkgPath);
},
afterBuild: async (log) => {
log('copying deps');
const deps = [
'decamelize',
'zod',
'zod-to-json-schema',
'langsmith',
'p-retry',
'p-queue',
'p-timeout',
'p-finally',
'mustache',
// 'js-tiktoken/lite',
'@cfworker/json-schema',
];
for (const dep of deps) {
const depPath = path.resolve(process.cwd(), 'node_modules', dep);
await fs.promises.cp(depPath, path.resolve(__dirname, 'dist/node_modules/@langchain/core/node_modules', dep), {
recursive: true,
force: true,
});
}
log('copying js-tiktoken/lite');
const files = ['lite.d.ts', 'lite.js', 'lite.cjs'];
for (const file of files) {
const depPath = path.dirname(require.resolve('js-tiktoken/lite'));
const filePath = path.resolve(depPath, file);
await fs.promises.cp(
filePath,
path.resolve(__dirname, 'dist/node_modules/@langchain/core/node_modules/js-tiktoken', file),
{
recursive: true,
force: true,
},
);
}
},
});

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,20 @@
{
"name": "@nocobase/plugin-ai",
"displayName": "AI integration",
"displayName.zh-CN": "AI 集成",
"description": "Support integration with AI services, providing AI-related workflow nodes to enhance business processing capabilities.",
"description.zh-CN": "支持接入 AI 服务,提供 AI 相关的工作流节点,增强业务处理能力。",
"version": "1.6.0-beta.16",
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/plugin-workflow": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"devDependencies": {
"@langchain/core": "^0.3.39",
"@langchain/deepseek": "^0.0.1",
"@langchain/openai": "^0.4.3"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

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 { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { namespace, useT } from '../locale';
import { tval } from '@nocobase/utils/client';
import { ArrayCollapse, FormLayout } from '@formily/antd-v5';
import { useField, observer } from '@formily/react';
import { Field } from '@formily/core';
const UserMessage: React.FC = observer(() => {
const t = useT();
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
content: {
title: t('Content'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableRawTextArea',
'x-component-props': {
autoSize: {
minRows: 5,
},
},
},
},
}}
/>
);
});
const Content: React.FC = observer(() => {
const t = useT();
const field = useField();
const role = field.query('.role').take() as Field;
if (role.value === 'user') {
return (
<SchemaComponent
components={{ UserMessage }}
schema={{
type: 'void',
properties: {
content: {
type: 'array',
'x-component': 'ArrayCollapse',
'x-component-props': {
size: 'small',
bordered: false,
},
default: [{ type: 'text' }],
'x-decorator': 'FormItem',
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
header: t('Content'),
},
properties: {
form: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: {
type: {
title: t('Type'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [{ label: t('Text'), value: 'text' }],
default: 'text',
},
user: {
type: 'void',
'x-component': 'UserMessage',
},
},
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
},
},
properties: {
addition: {
type: 'void',
title: tval('Add content', { ns: namespace }),
'x-component': 'ArrayCollapse.Addition',
'x-component-props': {
defaultValue: { type: 'text' },
},
},
},
},
},
}}
/>
);
}
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
message: {
title: tval('Content', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableRawTextArea',
},
},
}}
/>
);
});
export const Messages: React.FC = () => {
const t = useT();
return (
<SchemaComponent
components={{ ArrayCollapse, FormLayout, Content }}
schema={{
type: 'void',
properties: {
messages: {
type: 'array',
'x-component': 'ArrayCollapse',
'x-component-props': {
size: 'small',
},
'x-decorator': 'FormItem',
default: [{ role: 'user', content: [{ type: 'text' }] }],
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
header: t('Message'),
},
properties: {
form: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: {
role: {
title: tval('Role', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ label: 'System', value: 'system' },
{ label: 'User', value: 'user' },
{ label: 'Assistant', value: 'assistant' },
],
default: 'user',
},
content: {
type: 'void',
'x-component': 'Content',
},
},
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
},
},
properties: {
addition: {
type: 'void',
title: tval('Add prompt', { ns: namespace }),
'x-component': 'ArrayCollapse.Addition',
'x-component-props': {
defaultValue: { role: 'user', content: [{ type: 'text' }] },
},
},
},
},
},
}}
/>
);
};
export const MessagesSettings: React.FC = () => {
return (
<SchemaComponent
components={{ Messages }}
schema={{
type: 'void',
properties: {
messages: {
type: 'void',
'x-component': 'Messages',
},
},
}}
/>
);
};

View File

@ -0,0 +1,249 @@
/**
* 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.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -0,0 +1,61 @@
/**
* 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 { Plugin, lazy } from '@nocobase/client';
import { AIManager } from './manager/ai-manager';
import { openaiProviderOptions } from './llm-providers/openai';
import { deepseekProviderOptions } from './llm-providers/deepseek';
import PluginWorkflowClient from '@nocobase/plugin-workflow/client';
import { LLMInstruction } from './workflow/nodes/llm';
import { tval } from '@nocobase/utils/client';
import { namespace } from './locale';
const { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices');
const { MessagesSettings } = lazy(() => import('./chat-settings/Messages'), 'MessagesSettings');
const { Chat } = lazy(() => import('./llm-providers/components/Chat'), 'Chat');
const { ModelSelect } = lazy(() => import('./llm-providers/components/ModelSelect'), 'ModelSelect');
export class PluginAIClient extends Plugin {
aiManager = new AIManager();
async afterAdd() {
// await this.app.pm.add()
}
async beforeLoad() {}
// You can get and modify the app instance here
async load() {
this.app.pluginSettingsManager.add('ai', {
icon: 'RobotOutlined',
title: tval('AI integration', { ns: namespace }),
aclSnippet: 'pm.ai',
});
this.app.pluginSettingsManager.add('ai.llm-services', {
icon: 'LinkOutlined',
title: tval('LLM services', { ns: namespace }),
aclSnippet: 'pm.ai.llm-services',
Component: LLMServices,
});
this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
this.aiManager.registerLLMProvider('deepseek', deepseekProviderOptions);
this.aiManager.chatSettings.set('messages', {
title: tval('Messages'),
Component: MessagesSettings,
});
const workflow = this.app.pm.get('workflow') as PluginWorkflowClient;
workflow.registerInstructionGroup('ai', { label: tval('AI', { ns: namespace }) });
workflow.registerInstruction('llm', LLMInstruction);
}
}
export default PluginAIClient;
export { Chat, ModelSelect };
export type { LLMProviderOptions } from './manager/ai-manager';

View File

@ -0,0 +1,27 @@
/**
* 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 { Tabs } from 'antd';
import { usePlugin } from '@nocobase/client';
import PluginAIClient from '../..';
import { Schema } from '@formily/react';
import { useT } from '../../locale';
export const Chat: React.FC = () => {
const t = useT();
const plugin = usePlugin('ai') as PluginAIClient;
const chatSettings = Array.from(plugin.aiManager.chatSettings.entries());
const items = chatSettings.map(([key, { title, Component }]) => ({
key,
label: Schema.compile(title, { t }),
children: <Component />,
}));
return <Tabs items={items} />;
};

View File

@ -0,0 +1,250 @@
/**
* 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 { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { namespace, useT } from '../../locale';
import { tval } from '@nocobase/utils/client';
import { ArrayCollapse, FormLayout } from '@formily/antd-v5';
import { useField, observer } from '@formily/react';
import { Field } from '@formily/core';
import { WorkflowVariableInput } from '@nocobase/plugin-workflow/client';
const UserMessage: React.FC = observer(() => {
const t = useT();
const field = useField();
const type = field.query('.type').take() as Field;
if (type.value === 'image_url') {
return (
<SchemaComponent
components={{ WorkflowVariableInput }}
schema={{
type: 'void',
properties: {
image_url: {
type: 'object',
properties: {
url: {
title: tval('Image', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableInput',
'x-component-props': {
changeOnSelect: true,
},
},
},
},
},
}}
/>
);
}
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
content: {
title: t('Content'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableRawTextArea',
'x-component-props': {
autoSize: {
minRows: 5,
},
},
},
},
}}
/>
);
});
const Content: React.FC = observer(() => {
const t = useT();
const field = useField();
const role = field.query('.role').take() as Field;
if (role.value === 'user') {
return (
<SchemaComponent
components={{ UserMessage }}
schema={{
type: 'void',
properties: {
content: {
type: 'array',
'x-component': 'ArrayCollapse',
'x-component-props': {
size: 'small',
bordered: false,
},
default: [{ type: 'text' }],
'x-decorator': 'FormItem',
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
header: t('Content'),
},
properties: {
form: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: {
type: {
title: t('Type'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ label: t('Text'), value: 'text' },
{ label: t('Image'), value: 'image_url' },
],
default: 'text',
},
user: {
type: 'void',
'x-component': 'UserMessage',
},
},
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
},
},
properties: {
addition: {
type: 'void',
title: tval('Add content', { ns: namespace }),
'x-component': 'ArrayCollapse.Addition',
'x-component-props': {
defaultValue: { type: 'text' },
},
},
},
},
},
}}
/>
);
}
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
message: {
title: tval('Content', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableRawTextArea',
},
},
}}
/>
);
});
export const Messages: React.FC = () => {
const t = useT();
return (
<SchemaComponent
components={{ ArrayCollapse, FormLayout, Content }}
schema={{
type: 'void',
properties: {
messages: {
type: 'array',
'x-component': 'ArrayCollapse',
'x-component-props': {
size: 'small',
},
'x-decorator': 'FormItem',
default: [{ role: 'user', content: [{ type: 'text' }] }],
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
header: t('Message'),
},
properties: {
form: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: {
role: {
title: tval('Role', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ label: 'System', value: 'system' },
{ label: 'User', value: 'user' },
{ label: 'Assistant', value: 'assistant' },
],
default: 'user',
},
content: {
type: 'void',
'x-component': 'Content',
},
},
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
},
},
properties: {
addition: {
type: 'void',
title: tval('Add prompt', { ns: namespace }),
'x-component': 'ArrayCollapse.Addition',
'x-component-props': {
defaultValue: { role: 'user', content: [{ type: 'text' }] },
},
},
},
},
},
}}
/>
);
};

View File

@ -0,0 +1,75 @@
/**
* 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, { useState } from 'react';
import { useAPIClient, useActionContext, useRequest } from '@nocobase/client';
import { useForm, useField } from '@formily/react';
import { AutoComplete, Spin } from 'antd';
import { Field } from '@formily/core';
export const ModelSelect: React.FC = () => {
const field = useField<Field>();
const form = useForm();
const api = useAPIClient();
const ctx = useActionContext();
const [options, setOptions] = useState([]);
const { data: models, loading } = useRequest<
{
label: string;
value: string;
}[]
>(
() =>
api
.resource('ai')
.listModels({
llmService: form.values?.llmService,
})
.then(
(res) =>
res?.data?.data?.map(
({ id }) =>
({
label: id,
value: id,
}) || [],
),
),
{
ready: !!form.values?.llmService && ctx.visible,
refreshDeps: [form.values?.llmService],
onSuccess: (data) => setOptions(data),
},
);
const handleSearch = (value: string) => {
if (!models) {
setOptions([]);
return;
}
if (!value) {
setOptions(models);
return;
}
const searchOptions = models.filter((option) => {
return option.label.toLowerCase().includes(value.toLowerCase());
});
setOptions(searchOptions);
};
return (
<AutoComplete
onSearch={handleSearch}
options={options}
notFoundContent={loading ? <Spin size="small" /> : null}
value={field.value}
onChange={(val) => (field.value = val)}
/>
);
};

View File

@ -0,0 +1,70 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useT } from '../../locale';
import { WorkflowVariableJSON } from '@nocobase/plugin-workflow/client';
export const StructuredOutput: React.FC = () => {
const t = useT();
return (
<SchemaComponent
components={{ WorkflowVariableJSON }}
schema={{
type: 'void',
properties: {
structuredOutput: {
type: 'object',
properties: {
schema: {
title: 'JSON Schema',
type: 'string',
description: (
<>
{t('Syntax references')}:{' '}
<a href="https://json-schema.org" target="_blank" rel="noreferrer">
JSON Schema
</a>
</>
),
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableJSON',
'x-component-props': {
json5: true,
autoSize: {
minRows: 10,
},
},
},
name: {
title: t('Name'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
description: {
title: t('Description'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
strict: {
title: 'Strict',
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
},
},
},
}}
/>
);
};

View File

@ -0,0 +1,170 @@
/**
* 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 { SchemaComponent } from '@nocobase/client';
import { tval } from '@nocobase/utils/client';
import { namespace, useT } from '../../locale';
import { Collapse } from 'antd';
import { WorkflowVariableRawTextArea } from '@nocobase/plugin-workflow/client';
import { ModelSelect } from '../components/ModelSelect';
import { Chat } from '../components/Chat';
const Options: React.FC = () => {
const t = useT();
return (
<div
style={{
marginBottom: 24,
}}
>
<Collapse
bordered={false}
size="small"
items={[
{
key: 'options',
label: t('Options'),
forceRender: true,
children: (
<SchemaComponent
schema={{
type: 'void',
name: 'deepseek',
properties: {
frequencyPenalty: {
title: tval('Frequency penalty', { ns: namespace }),
description: tval('Frequency penalty description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.0,
'x-component-props': {
step: 0.1,
min: -2.0,
max: 2.0,
},
},
maxCompletionTokens: {
title: tval('Max completion 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 }),
description: tval('Presence penalty description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.0,
'x-component-props': {
step: 0.1,
min: -2.0,
max: 2.0,
},
},
temperature: {
title: tval('Temperature', { ns: namespace }),
description: tval('Temperature description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.7,
'x-component-props': {
step: 0.1,
min: 0.0,
max: 1.0,
},
},
topP: {
title: tval('Top P', { ns: namespace }),
description: tval('Top P description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 1.0,
'x-component-props': {
step: 0.5,
min: 0.0,
max: 1.0,
},
},
responseFormat: {
title: tval('Response format', { ns: namespace }),
description: tval('Response format description', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{
label: t('Text'),
value: 'text',
},
{
label: t('JSON'),
value: 'json_object',
},
],
default: 'text',
},
timeout: {
title: tval('Timeout (ms)', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 60000,
},
maxRetries: {
title: tval('Max retries', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 1,
},
},
}}
/>
),
},
]}
/>
</div>
);
};
export const ModelSettingsForm: React.FC = () => {
return (
<SchemaComponent
components={{ Options, WorkflowVariableRawTextArea, ModelSelect, Chat }}
schema={{
type: 'void',
properties: {
model: {
title: tval('Model', { ns: namespace }),
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'ModelSelect',
},
options: {
type: 'void',
'x-component': 'Options',
},
chat: {
type: 'void',
'x-component': 'Chat',
},
},
}}
/>
);
};

View File

@ -0,0 +1,19 @@
/**
* 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 { LLMProviderOptions } from '../../manager/ai-manager';
import { ProviderSettingsForm } from '../openai/ProviderSettings';
import { ModelSettingsForm } from './ModelSettings';
export const deepseekProviderOptions: LLMProviderOptions = {
components: {
ProviderSettingsForm,
ModelSettingsForm,
},
};

View File

@ -0,0 +1,174 @@
/**
* 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 { SchemaComponent } from '@nocobase/client';
import { tval } from '@nocobase/utils/client';
import { namespace, useT } from '../../locale';
import { Collapse } from 'antd';
import { WorkflowVariableRawTextArea } from '@nocobase/plugin-workflow/client';
import { ModelSelect } from '../components/ModelSelect';
import { Chat } from '../components/Chat';
const Options: React.FC = () => {
const t = useT();
return (
<div
style={{
marginBottom: 24,
}}
>
<Collapse
bordered={false}
size="small"
items={[
{
key: 'options',
label: t('Options'),
forceRender: true,
children: (
<SchemaComponent
schema={{
type: 'void',
name: 'openai',
properties: {
frequencyPenalty: {
title: tval('Frequency penalty', { ns: namespace }),
description: tval('Frequency penalty description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.0,
'x-component-props': {
step: 0.1,
min: -2.0,
max: 2.0,
},
},
maxCompletionTokens: {
title: tval('Max completion 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 }),
description: tval('Presence penalty description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.0,
'x-component-props': {
step: 0.1,
min: -2.0,
max: 2.0,
},
},
temperature: {
title: tval('Temperature', { ns: namespace }),
description: tval('Temperature description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 0.7,
'x-component-props': {
step: 0.1,
min: 0.0,
max: 1.0,
},
},
topP: {
title: tval('Top P', { ns: namespace }),
description: tval('Top P description', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 1.0,
'x-component-props': {
step: 0.5,
min: 0.0,
max: 1.0,
},
},
responseFormat: {
title: tval('Response format', { ns: namespace }),
description: tval('Response format description', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{
label: t('Text'),
value: 'text',
},
{
label: t('JSON'),
value: 'json_object',
},
{
label: t('JSON Schema'),
value: 'json_schema',
},
],
default: 'text',
},
timeout: {
title: tval('Timeout (ms)', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 60000,
},
maxRetries: {
title: tval('Max retries', { ns: namespace }),
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: 1,
},
},
}}
/>
),
},
]}
/>
</div>
);
};
export const ModelSettingsForm: React.FC = () => {
return (
<SchemaComponent
components={{ Options, WorkflowVariableRawTextArea, ModelSelect, Chat }}
schema={{
type: 'void',
properties: {
model: {
title: tval('Model', { ns: namespace }),
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'ModelSelect',
},
options: {
type: 'void',
'x-component': 'Options',
},
chat: {
type: 'void',
'x-component': 'Chat',
},
},
}}
/>
);
};

View File

@ -0,0 +1,38 @@
/**
* 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 { SchemaComponent } from '@nocobase/client';
import { tval } from '@nocobase/utils/client';
import { namespace } from '../../locale';
export const ProviderSettingsForm: React.FC = () => {
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
apiKey: {
title: tval('API key', { ns: namespace }),
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'TextAreaWithGlobalScope',
},
baseURL: {
title: tval('Base URL', { ns: namespace }),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'TextAreaWithGlobalScope',
},
},
}}
/>
);
};

View File

@ -0,0 +1,19 @@
/**
* 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 { LLMProviderOptions } from '../../manager/ai-manager';
import { ModelSettingsForm } from './ModelSettings';
import { ProviderSettingsForm } from './ProviderSettings';
export const openaiProviderOptions: LLMProviderOptions = {
components: {
ProviderSettingsForm,
ModelSettingsForm,
},
};

View File

@ -0,0 +1,206 @@
/**
* 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 {
ActionContextProvider,
ExtendCollectionsProvider,
SchemaComponent,
useAPIClient,
useActionContext,
useCollection,
useCollectionRecordData,
useDataBlockRequest,
useDataBlockResource,
usePlugin,
useRequest,
} from '@nocobase/client';
import React, { useContext, useMemo, useState } from 'react';
import { useT } from '../locale';
import { Button, Dropdown, App } from 'antd';
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
import llmServices from '../../collections/llm-services';
import { llmsSchema, createLLMSchema } from '../schemas/llms';
import { LLMProviderContext, LLMProvidersContext, useLLMProviders } from './llm-providers';
import { Schema, useForm, observer } from '@formily/react';
import { createForm } from '@formily/core';
import { uid } from '@formily/shared';
import PluginAIClient from '..';
const useCreateFormProps = () => {
const { provider } = useContext(LLMProviderContext);
const form = useMemo(
() =>
createForm({
initialValues: {
name: `v_${uid()}`,
provider,
},
}),
[provider],
);
return {
form,
};
};
const useEditFormProps = () => {
const record = useCollectionRecordData();
const form = useMemo(
() =>
createForm({
initialValues: record,
}),
[record],
);
return {
form,
};
};
const useCancelActionProps = () => {
const { setVisible } = useActionContext();
const form = useForm();
return {
type: 'default',
onClick() {
setVisible(false);
form.reset();
},
};
};
const useCreateActionProps = () => {
const { setVisible } = useActionContext();
const { message } = App.useApp();
const form = useForm();
const resource = useDataBlockResource();
const { refresh } = useDataBlockRequest();
const t = useT();
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
await resource.create({
values,
});
refresh();
message.success(t('Saved successfully'));
setVisible(false);
form.reset();
},
};
};
const useEditActionProps = () => {
const { setVisible } = useActionContext();
const { message } = App.useApp();
const form = useForm();
const resource = useDataBlockResource();
const { refresh } = useDataBlockRequest();
const collection = useCollection();
const filterTk = collection.getFilterTargetKey();
const t = useT();
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
await resource.update({
values,
filterByTk: values[filterTk],
});
refresh();
message.success(t('Saved successfully'));
setVisible(false);
form.reset();
},
};
};
const AddNew = () => {
const t = useT();
const [visible, setVisible] = useState(false);
const [provider, setProvider] = useState('');
const providers = useLLMProviders();
const items = providers.map((item) => ({
...item,
onClick: () => {
setVisible(true);
setProvider(item.value);
},
}));
return (
<ActionContextProvider value={{ visible, setVisible }}>
<LLMProviderContext.Provider value={{ provider }}>
<Dropdown menu={{ items }}>
<Button icon={<PlusOutlined />} type={'primary'}>
{t('Add new')} <DownOutlined />
</Button>
</Dropdown>
<SchemaComponent scope={{ setProvider, useCreateFormProps }} schema={createLLMSchema} />
</LLMProviderContext.Provider>
</ActionContextProvider>
);
};
export const useProviderSettingsForm = (provider: string) => {
const plugin = usePlugin('ai') as PluginAIClient;
const p = plugin.aiManager.llmProviders.get(provider);
return p?.components?.ProviderSettingsForm;
};
export const Settings = observer(
() => {
const form = useForm();
const record = useCollectionRecordData();
const Component = useProviderSettingsForm(form.values.provider || record.provider);
return Component ? <Component /> : null;
},
{ displayName: 'LLMProviderSettings' },
);
export const LLMServices: React.FC = () => {
const t = useT();
const [providers, setProviders] = useState([]);
const api = useAPIClient();
useRequest(
() =>
api
.resource('ai')
.listLLMProviders()
.then((res) => {
const providers = res?.data?.data || [];
return providers.map((provider: { name: string; title?: string }) => ({
key: provider.name,
label: Schema.compile(provider.title || provider.name, { t }),
value: provider.name,
}));
}),
{
onSuccess: (providers) => {
setProviders(providers);
},
},
);
return (
<LLMProvidersContext.Provider value={{ providers }}>
<ExtendCollectionsProvider collections={[llmServices]}>
<SchemaComponent
schema={llmsSchema}
components={{ AddNew, Settings }}
scope={{ t, providers, useEditFormProps, useCancelActionProps, useCreateActionProps, useEditActionProps }}
/>
</ExtendCollectionsProvider>
</LLMProvidersContext.Provider>
);
};

View File

@ -0,0 +1,29 @@
/**
* 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 { createContext, useContext } from 'react';
export const LLMProviderContext = createContext<{
provider: string;
}>({ provider: '' });
LLMProviderContext.displayName = 'LLMProvidersContext';
export const LLMProvidersContext = createContext<{
providers: {
key: string;
label: string;
value: string;
}[];
}>({ providers: [] });
LLMProvidersContext.displayName = 'LLMProviderssContext';
export const useLLMProviders = () => {
const { providers } = useContext(LLMProvidersContext);
return providers;
};

View File

@ -0,0 +1,23 @@
/**
* 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.
*/
// @ts-ignore
import pkg from './../../package.json';
import { useApp } from '@nocobase/client';
export const namespace = pkg.name;
export function useT() {
const app = useApp();
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
}
export function tStr(key: string) {
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
}

View File

@ -0,0 +1,33 @@
/**
* 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 { Registry } from '@nocobase/utils/client';
import { ComponentType } from 'react';
export type LLMProviderOptions = {
components: {
ProviderSettingsForm?: ComponentType;
ModelSettingsForm?: ComponentType;
};
};
export class AIManager {
llmProviders = new Registry<LLMProviderOptions>();
chatSettings = new Map<
string,
{
title: string;
Component: ComponentType;
}
>();
registerLLMProvider(name: string, options: LLMProviderOptions) {
this.llmProviders.register(name, options);
}
}

View File

@ -0,0 +1,255 @@
/**
* 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 const createLLMSchema = {
type: 'void',
properties: {
drawer: {
type: 'void',
title: '{{ t("Add new") }}',
'x-component': 'Action.Drawer',
'x-decorator': 'FormV2',
'x-use-decorator-props': 'useCreateFormProps',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("UID") }}',
'x-component': 'Input',
},
title: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("Title") }}',
'x-component': 'Input',
},
options: {
type: 'object',
'x-component': 'Settings',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-use-component-props': 'useCancelActionProps',
},
submit: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
'x-use-component-props': 'useCreateActionProps',
},
},
},
},
},
},
};
export const llmsSchema = {
type: 'void',
properties: {
card: {
type: 'void',
'x-component': 'CardItem',
'x-component-props': {
heightMode: 'fullHeight',
},
'x-decorator': 'TableBlockProvider',
'x-decorator-props': {
collection: 'llmServices',
action: 'list',
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 20,
},
},
properties: {
filter: {
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
title: "{{t('Filter')}}",
'x-component-props': {
icon: 'FilterOutlined',
},
'x-align': 'left',
},
refresh: {
title: "{{t('Refresh')}}",
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
icon: 'ReloadOutlined',
},
},
bulkDelete: {
title: "{{t('Delete')}}",
'x-action': 'destroy',
'x-component': 'Action',
'x-use-component-props': 'useBulkDestroyActionProps',
'x-component-props': {
icon: 'DeleteOutlined',
confirm: {
title: "{{t('Delete record')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
add: {
type: 'void',
'x-component': 'AddNew',
title: "{{t('Add new')}}",
'x-align': 'right',
},
},
},
table: {
type: 'array',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
properties: {
column1: {
type: 'void',
title: '{{ t("UID") }}',
'x-component': 'TableV2.Column',
properties: {
name: {
type: 'string',
'x-component': 'Input',
'x-read-pretty': true,
},
},
},
column2: {
type: 'void',
title: '{{ t("Title") }}',
'x-component': 'TableV2.Column',
properties: {
title: {
type: 'string',
'x-component': 'Input',
'x-read-pretty': true,
},
},
},
column3: {
type: 'void',
title: '{{ t("Provider") }}',
'x-component': 'TableV2.Column',
properties: {
provider: {
type: 'string',
'x-component': 'Select',
'x-read-pretty': true,
enum: '{{ providers }}',
},
},
},
column4: {
type: 'void',
title: '{{ t("Actions") }}',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
edit: {
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
properties: {
drawer: {
type: 'void',
title: '{{ t("Edit record") }}',
'x-component': 'Action.Drawer',
'x-decorator': 'FormV2',
'x-use-decorator-props': 'useEditFormProps',
properties: {
title: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("Title") }}',
'x-component': 'Input',
},
options: {
type: 'object',
'x-component': 'Settings',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-use-component-props': 'useCancelActionProps',
},
submit: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
'x-use-component-props': 'useEditActionProps',
},
},
},
},
},
},
},
destroy: {
type: 'void',
title: '{{ t("Delete") }}',
'x-action': 'destroy',
'x-component': 'Action.Link',
'x-use-component-props': 'useDestroyActionProps',
'x-component-props': {
confirm: {
title: "{{t('Delete record')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,43 @@
/**
* 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 { observer, useForm } from '@formily/react';
import { useAPIClient, usePlugin, useRequest } from '@nocobase/client';
import React from 'react';
import PluginAIClient from '../../../';
export const useModelSettingsForm = (provider: string) => {
const plugin = usePlugin(PluginAIClient);
const p = plugin.aiManager.llmProviders.get(provider);
return p?.components?.ModelSettingsForm;
};
export const Settings = observer(
() => {
const form = useForm();
const api = useAPIClient();
const { data, loading } = useRequest<{ provider: string }>(
() =>
api
.resource('llmServices')
.get({ filterByTk: form.values?.llmService })
.then((res) => res?.data?.data),
{
ready: !!form.values?.llmService,
refreshDeps: [form.values?.llmService],
},
);
const Component = useModelSettingsForm(data?.provider);
if (loading) {
return null;
}
return Component ? <Component /> : null;
},
{ displayName: 'WorkflowLLMModelSettingsForm' },
);

View File

@ -0,0 +1,79 @@
/**
* 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 { Instruction } from '@nocobase/plugin-workflow/client';
import { RobotOutlined } from '@ant-design/icons';
import { tval } from '@nocobase/utils/client';
import { namespace } from '../../../locale';
import { Settings } from './ModelSettings';
export class LLMInstruction extends Instruction {
title = 'LLM';
type = 'llm';
group = 'ai';
// @ts-ignore
icon = (<RobotOutlined />);
fieldset = {
llmService: {
type: 'string',
title: tval('LLM service', { ns: namespace }),
name: 'llmService',
required: true,
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
service: {
resource: 'llmServices',
action: 'list',
params: {
fields: ['title', 'name'],
},
},
},
},
options: {
type: 'void',
'x-component': 'Settings',
},
};
components = {
Settings,
};
isAvailable({ engine, workflow }) {
return !engine.isWorkflowSync(workflow);
}
useVariables(node, options) {
return {
label: node.title,
value: node.key,
children: [
{
value: 'content',
label: 'Content',
},
{
value: 'structuredContent',
label: 'Structured content',
},
{
value: 'additionalKwargs',
label: 'Additional Kwargs',
},
],
};
}
}

View File

@ -0,0 +1,41 @@
/**
* 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 default {
name: 'llmServices',
fields: [
{
name: 'name',
type: 'uid',
primaryKey: true,
},
{
name: 'title',
type: 'string',
interface: 'input',
uiSchema: {
title: '{{t("Title")}}',
'x-component': 'Input',
},
},
{
name: 'provider',
type: 'string',
interface: 'select',
uiSchema: {
title: '{{t("Provider")}}',
'x-component': 'Select',
},
},
{
name: 'options',
type: 'jsonb',
},
],
};

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 './server';
export { default } from './server';

View File

@ -0,0 +1,25 @@
{
"AI integration": "AI integration",
"LLM services": "LLM services",
"LLM service": "LLM service",
"Model": "Model",
"Messages": "Messages",
"Structured output": "Structured output",
"Message": "Message",
"Role": "Role",
"UID": "UID",
"Add content": "Add content",
"Add prompt": "Add prompt",
"Provider": "Provider",
"Text": "Text",
"Image": "Image",
"Timout (ms)": "Timout (ms)",
"Max retries": "Max retries",
"Frequency penalty description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.",
"Max completion tokens description": "An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens.",
"Presence penalty description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.",
"Response format description": "Important: when using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message.",
"Temperature description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"Top P description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.",
"Get models list failed, you can enter a model name manually.": "Get models list failed, you can enter a model name manually."
}

View File

@ -0,0 +1,25 @@
{
"AI integration": "AI 集成",
"LLM services": "LLM 服务",
"LLM service": "LLM 服务",
"Model": "模型",
"UID": "唯一标识",
"Provider": "LLM 类型",
"Messages": "消息",
"Structured output": "结构化输出",
"Message": "消息",
"Role": "角色",
"Add content": "添加内容",
"Add prompt": "添加提示",
"Text": "文本",
"Image": "图片",
"Timout (ms)": "超时时间(毫秒)",
"Max retries": "最大重试次数",
"Frequency penalty description": "介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。",
"Max completion tokens description": "限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。",
"Presence penalty description": "介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。",
"Response format description": "使用 JSON 模式时,你还必须通过系统或用户消息指示模型生成 JSON。",
"Temperature description": "采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。",
"Top P description": "作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。",
"Get models list failed, you can enter a model name manually.": "获取模型列表失败,你可以手动输入模型名称。"
}

View File

@ -0,0 +1,17 @@
/**
* 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 { defineCollection } from '@nocobase/database';
import llmServices from '../../collections/llm-services';
export default defineCollection({
migrationRules: ['overwrite', 'schema-only'],
autoGenId: false,
...llmServices,
});

View File

@ -0,0 +1,12 @@
/**
* 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 { default } from './plugin';
export { LLMProvider } from './llm-providers/provider';
export { LLMProviderOptions } from './manager/ai-manager';

View File

@ -0,0 +1,39 @@
/**
* 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 { ChatDeepSeek } from '@langchain/deepseek';
import { LLMProvider } from './provider';
import { LLMProviderOptions } from '../manager/ai-manager';
export class DeepSeekProvider extends LLMProvider {
baseURL = 'https://api.deepseek.com';
createModel() {
const { baseURL, apiKey } = this.serviceOptions || {};
const { responseFormat } = this.modelOptions || {};
return new ChatDeepSeek({
apiKey,
...this.modelOptions,
modelKwargs: {
response_format: {
type: responseFormat,
},
},
configuration: {
baseURL: baseURL || this.baseURL,
},
});
}
}
export const deepseekProviderOptions: LLMProviderOptions = {
title: 'DeepSeek',
provider: DeepSeekProvider,
};

View File

@ -0,0 +1,54 @@
/**
* 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.
*/
type Message = {
role: 'user' | 'system' | 'assistant';
message?: string;
content?: {
type: 'text';
content?: string;
}[];
};
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
async function parseMessage(message: Message) {
switch (message.role) {
case 'system':
return new SystemMessage(message.message);
case 'assistant':
return new AIMessage(message.message);
case 'user': {
if (message.content.length === 1) {
const msg = message.content[0];
return new HumanMessage(msg.content);
}
const content = [];
for (const c of message.content) {
if (c.type === 'text') {
content.push({
type: 'text',
text: c.content,
});
}
}
return new HumanMessage({
content,
});
}
}
}
export async function parseMessages() {
const msgs = [];
for (const message of this.messages) {
const msg = await parseMessage(message);
msgs.push(msg);
}
this.messages = msgs;
}

View File

@ -0,0 +1,42 @@
/**
* 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 { ChatOpenAI } from '@langchain/openai';
import { LLMProvider } from './provider';
export class OpenAIProvider extends LLMProvider {
baseURL = 'https://api.openai.com/v1';
createModel() {
const { baseURL, apiKey } = this.serviceOptions || {};
const { responseFormat, structuredOutput } = this.modelOptions || {};
const { schema } = structuredOutput || {};
const responseFormatOptions = {
type: responseFormat,
};
if (responseFormat === 'json_schema' && schema) {
responseFormatOptions['json_schema'] = schema;
}
return new ChatOpenAI({
apiKey,
...this.modelOptions,
modelKwargs: {
response_format: responseFormatOptions,
},
configuration: {
baseURL: baseURL || this.baseURL,
},
});
}
}
export const openaiProviderOptions = {
title: 'OpenAI',
provider: OpenAIProvider,
};

View File

@ -0,0 +1,86 @@
/**
* 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 { BaseChatModel } from '@langchain/core/language_models/chat_models';
import axios from 'axios';
import { parseMessages } from './handlers/parse-messages';
import { Application } from '@nocobase/server';
export abstract class LLMProvider {
baseURL?: string;
serviceOptions: Record<string, any>;
modelOptions: Record<string, any>;
messages: any[];
chatModel: any;
chatHandlers = new Map<string, () => Promise<void> | void>();
abstract createModel(): BaseChatModel;
constructor(opts: {
app: Application;
serviceOptions: any;
chatOptions?: {
messages?: any[];
[key: string]: any;
};
}) {
const { app, serviceOptions, chatOptions } = opts;
this.serviceOptions = app.environment.renderJsonTemplate(serviceOptions);
if (chatOptions) {
const { messages, ...modelOptions } = chatOptions;
this.modelOptions = modelOptions;
this.messages = messages;
this.chatModel = this.createModel();
this.registerChatHandler('parse-messages', parseMessages);
}
}
registerChatHandler(name: string, handler: () => Promise<void> | void) {
this.chatHandlers.set(name, handler.bind(this));
}
async invokeChat() {
for (const handler of this.chatHandlers.values()) {
await handler();
}
return this.chatModel.invoke(this.messages);
}
async listModels(): Promise<{
models?: { id: string }[];
code?: number;
errMsg?: string;
}> {
const options = this.serviceOptions || {};
const apiKey = options.apiKey;
let baseURL = options.baseURL || this.baseURL;
if (!baseURL) {
return { code: 400, errMsg: 'baseURL is required' };
}
if (!apiKey) {
return { code: 400, errMsg: 'API Key required' };
}
if (baseURL && baseURL.endsWith('/')) {
baseURL = baseURL.slice(0, -1);
}
try {
if (baseURL && baseURL.endsWith('/')) {
baseURL = baseURL.slice(0, -1);
}
const res = await axios.get(`${baseURL}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
return { models: res?.data.data };
} catch (e) {
return { code: 500, errMsg: e.message };
}
}
}

View File

@ -0,0 +1,29 @@
/**
* 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 { Application } from '@nocobase/server';
import { LLMProvider } from '../llm-providers/provider';
export type LLMProviderOptions = {
title: string;
provider: new (opts: { app: Application; serviceOptions?: any; chatOptions?: any }) => LLMProvider;
};
export class AIManager {
llmProviders = new Map<string, LLMProviderOptions>();
registerLLMProvider(name: string, options: LLMProviderOptions) {
this.llmProviders.set(name, options);
}
listLLMProviders() {
const providers = this.llmProviders.entries();
return Array.from(providers).map(([name, { title }]) => ({ name, title }));
}
}

View File

@ -0,0 +1,52 @@
/**
* 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 { Plugin } from '@nocobase/server';
import { AIManager } from './manager/ai-manager';
import { openaiProviderOptions } from './llm-providers/openai';
import { deepseekProviderOptions } from './llm-providers/deepseek';
import aiResource from './resource/ai';
import PluginWorkflowServer from '@nocobase/plugin-workflow';
import { LLMInstruction } from './workflow/nodes/llm';
export class PluginAIServer extends Plugin {
aiManager = new AIManager();
async afterAdd() {}
async beforeLoad() {}
async load() {
this.aiManager.registerLLMProvider('openai', openaiProviderOptions);
this.aiManager.registerLLMProvider('deepseek', deepseekProviderOptions);
this.app.resourceManager.define(aiResource);
this.app.acl.registerSnippet({
name: `pm.${this.name}.llm-services`,
actions: ['ai:*', 'llmServices:*'],
});
const workflowSnippet = this.app.acl.snippetManager.snippets.get('pm.workflow.workflows');
if (workflowSnippet) {
workflowSnippet.actions.push('ai:listModels');
}
const workflow = this.app.pm.get('workflow') as PluginWorkflowServer;
workflow.registerInstruction('llm', LLMInstruction);
}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginAIServer;

View File

@ -0,0 +1,53 @@
/**
* 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 { ResourceOptions } from '@nocobase/resourcer';
import { PluginAIServer } from '../plugin';
const aiResource: ResourceOptions = {
name: 'ai',
actions: {
listLLMProviders: async (ctx, next) => {
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
ctx.body = plugin.aiManager.listLLMProviders();
await next();
},
listModels: async (ctx, next) => {
const { llmService } = ctx.action.params;
const plugin = ctx.app.pm.get('ai') as PluginAIServer;
const service = await ctx.db.getRepository('llmServices').findOne({
filter: {
name: llmService,
},
});
if (!service) {
ctx.throw(400, 'invalid llm service');
}
const providerOptions = plugin.aiManager.llmProviders.get(service.provider);
if (!providerOptions) {
ctx.throw(400, 'invalid llm provider');
}
const options = service.options;
const Provider = providerOptions.provider;
const provider = new Provider({
app: ctx.app,
serviceOptions: options,
});
const res = await provider.listModels();
if (res.errMsg) {
ctx.log.error(res.errMsg);
ctx.throw(500, ctx.t('Get models list failed, you can enter a model name manually.'));
}
ctx.body = res.models || [];
return next();
},
},
};
export default aiResource;

View File

@ -0,0 +1,105 @@
/**
* 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 { FlowNodeModel, Instruction, JOB_STATUS, Processor } from '@nocobase/plugin-workflow';
import PluginAIServer from '../../../plugin';
import { LLMProvider } from '../../../llm-providers/provider';
import _ from 'lodash';
export class LLMInstruction extends Instruction {
async getLLMProvider(llmService: string, chatOptions: any) {
const service = await this.workflow.db.getRepository('llmServices').findOne({
filter: {
name: llmService,
},
});
if (!service) {
throw new Error('invalid llm service');
}
const plugin = this.workflow.app.pm.get('ai') as PluginAIServer;
const providerOptions = plugin.aiManager.llmProviders.get(service.provider);
if (!providerOptions) {
throw new Error('invalid llm provider');
}
const Provider = providerOptions.provider;
const provider = new Provider({ app: this.workflow.app, serviceOptions: service.options, chatOptions });
return provider;
}
async run(node: FlowNodeModel, input: any, processor: Processor) {
const { llmService, ...chatOptions } = processor.getParsedValue(node.config, node.id);
let provider: LLMProvider;
try {
provider = await this.getLLMProvider(llmService, chatOptions);
} catch (e) {
return {
status: JOB_STATUS.ERROR,
result: e.message,
};
}
const job = await processor.saveJob({
status: JOB_STATUS.PENDING,
nodeId: node.id,
nodeKey: node.key,
upstreamId: input?.id ?? null,
});
// eslint-disable-next-line promise/catch-or-return
provider
.invokeChat()
.then((aiMsg) => {
let raw = aiMsg;
if (aiMsg.raw) {
raw = aiMsg.raw;
}
job.set({
status: JOB_STATUS.RESOLVED,
result: {
id: raw.id,
content: raw.content,
additionalKwargs: raw.additional_kwargs,
responseMetadata: raw.response_metadata,
toolCalls: raw.tool_calls,
structuredContent: aiMsg.parsed,
},
});
})
.catch((e) => {
processor.logger.error(`llm invoke failed, ${e.message}`, {
node: node.id,
error: e,
chatOptions: _.omit(chatOptions, 'messages'),
});
job.set({
status: JOB_STATUS.ERROR,
result: e.message,
});
})
.finally(() => {
setImmediate(() => {
this.workflow.resume(job);
});
});
processor.logger.trace(`llm invoke, waiting for response...`, {
node: node.id,
});
return processor.exit();
}
resume(node: FlowNodeModel, job: any, processor: Processor) {
const { ignoreFail } = node.config;
if (ignoreFail) {
job.set('status', JOB_STATUS.RESOLVED);
}
return job;
}
}

View File

@ -19,6 +19,7 @@
"@nocobase/plugin-audit-logs": "1.6.0-beta.16",
"@nocobase/plugin-auth": "1.6.0-beta.16",
"@nocobase/plugin-auth-sms": "1.6.0-beta.16",
"@nocobase/plugin-ai": "1.6.0-beta.16",
"@nocobase/plugin-backup-restore": "1.6.0-beta.16",
"@nocobase/plugin-block-iframe": "1.6.0-beta.16",
"@nocobase/plugin-block-workbench": "1.6.0-beta.16",
@ -94,6 +95,7 @@
"@nocobase/plugin-action-print",
"@nocobase/plugin-auth",
"@nocobase/plugin-async-task-manager",
"@nocobase/plugin-ai",
"@nocobase/plugin-block-iframe",
"@nocobase/plugin-block-workbench",
"@nocobase/plugin-calendar",