feat: improvements for ai employee (#8191)

* feat: add  margin for ai hint message box

* feat(ai-employee): add submit validation for AI employee form

* fix: delete unnecessary build-in AI employee Avery

* feat: improve and unify copywriting style

* feat(ai-employee): define default role permissions for AI employees

* fix: ai employee add error handler

* feat: ai support external data source

* feat: add ai work context variables

* fix: ai work context read from ctx

* feat: ai tool i18n text

* feat: ai chat messages add token limit

* fix: ai builder employee add dara

* fix: resolve data modeling tool issues

* fix: ai tool getDataSources type and displayName
This commit is contained in:
Ziqiang
2025-12-23 14:22:42 +08:00
committed by GitHub
parent 8ac65b1ee2
commit 9ba16ad100
30 changed files with 310 additions and 164 deletions
@@ -7,6 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// Notice: This component is not used in the current version.
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
import { Alert } from 'antd';
@@ -19,7 +19,6 @@ import { ProfileSettings } from './ProfileSettings';
import { SystemPrompt } from './SystemPrompt';
import aiEmployees from '../../../collections/ai-employees';
import { SkillSettings } from './SkillSettings';
import { DataSourceSettings } from './DataSourceSettings';
import { Templates } from './Templates';
import {
useCreateFormProps,
@@ -59,8 +58,8 @@ const AIEmployeeForm: React.FC<{
forceRender: true,
},
{
key: 'persona',
label: t('Characterization'),
key: 'roleSetting',
label: t('Role setting'),
children: <SystemPrompt />,
forceRender: true,
},
@@ -80,11 +79,6 @@ const AIEmployeeForm: React.FC<{
label: t('Skills'),
children: <SkillSettings />,
},
// {
// key: 'dataSources',
// label: t('Data sources'),
// children: <DataSourceSettings />,
// },
...(knowledgeBaseEnabled
? [
{
@@ -78,7 +78,7 @@ const Description = () => {
style={{
marginBottom: 16,
}}
message={t('Characterization description')}
message={t('Role setting description')}
type="info"
/>
);
@@ -110,13 +110,13 @@ export const SystemPrompt: React.FC = () => {
properties: {
about: {
type: 'string',
title: '{{t("Characterization")}}',
title: '{{t("Role setting")}}',
required: true,
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: options,
placeholder: t('Characterization placeholder'),
placeholder: t('Role setting placeholder'),
autoSize: {
minRows: 15,
},
@@ -85,8 +85,17 @@ export const useCreateActionProps = () => {
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
if (!values?.about) {
message.warning(t('Please complete role setting before submitting'));
return;
}
const modelSettings = values?.modelSettings;
if (!modelSettings?.llmService || !modelSettings?.model) {
message.warning(t('Please complete model setting before submitting'));
return;
}
await form.submit();
await api.resource('aiEmployees').create({
values,
});
@@ -113,8 +122,17 @@ export const useEditActionProps = () => {
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
if (!values?.about) {
message.warning(t('Please complete persona before submitting'));
return;
}
const modelSettings = values?.modelSettings;
if (!modelSettings?.llmService || !modelSettings?.model) {
message.warning(t('Please complete model settings before submitting'));
return;
}
await form.submit();
await resource.update({
values,
filterByTk: values[filterTk],
@@ -134,9 +152,12 @@ export const useDeleteActionProps = () => {
const { onClick } = useDestroyActionProps();
const isBuiltIn = record?.builtIn;
const { message } = App.useApp();
const api = useAPIClient();
const isSuperUser = api.auth.role === 'root';
return {
async onClick(e?, callBack?) {
if (isBuiltIn) {
if (isBuiltIn && !isSuperUser) {
message.warning(t('Cannot delete built-in ai employees'));
return;
}
@@ -15,7 +15,8 @@ import { Avatar, Popover, Tooltip } from 'antd';
import { useChatMessagesStore } from '../chatbox/stores/chat-messages';
import { ProfileCard } from '../ProfileCard';
import { avatars } from '../avatars';
import { EditorRef } from '@nocobase/client';
import { EditorRef, useCompile } from '@nocobase/client';
import { useFlowContext } from '@nocobase/flow-engine';
import { isEngineer } from '../built-in/utils';
import { Task } from '../types';
import { useT } from '../../locale';
@@ -31,6 +32,7 @@ export interface AICodingButtonProps {
export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, language, editorRef, setActive }) => {
const t = useT();
const compile = useCompile();
const { aiEmployees } = useAIEmployeesData();
const open = useChatBoxStore.use.open();
const currentEmployee = useChatBoxStore.use.currentEmployee();
@@ -38,6 +40,24 @@ export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, lang
const addContextItems = useChatMessagesStore.use.addContextItems();
const setEditorRef = useChatMessagesStore.use.setEditorRef();
const setCurrentEditorRefUid = useChatMessagesStore.use.setCurrentEditorRefUid();
const ctx = useFlowContext();
const buildCtxVariablesDesc = () => {
const metaTree = ctx?.getPropertyMetaTree?.() || [];
return metaTree
.filter((node) => {
const disabled = typeof node.disabled === 'function' ? node.disabled() : node.disabled;
return !disabled;
})
.map((node) => {
const paths = node.paths?.length ? node.paths : [node.name].filter(Boolean);
const fullPath = ['ctx', ...paths].join('.');
const nodeTitle = compile(node.title, { t });
const title = nodeTitle && nodeTitle !== node.name ? ` (${nodeTitle})` : '';
return `- {{${fullPath}}}${title}`;
})
.join('\n');
};
const aiEmployee = aiEmployees.filter((e) => isEngineer(e))[0];
@@ -72,6 +92,7 @@ export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, lang
const TaskTemplate = (prototype: Partial<Task>) => {
const { message, ...rest } = prototype;
const variablesDesc = buildCtxVariablesDesc();
return {
message: {
workContext: [
@@ -85,7 +106,15 @@ export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, lang
code: editorRef?.read(),
},
},
],
variablesDesc
? {
type: 'text',
uid: 'available-variables',
title: 'Available variables',
content: `You can access the following variables via context:\n${variablesDesc}`,
}
: null,
].filter(Boolean),
...(message ?? {}),
},
autoSend: false,
@@ -139,6 +168,9 @@ export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, lang
}
setCurrentEditorRefUid(uid);
const variablesDesc = buildCtxVariablesDesc();
addContextItems({
type: 'code-editor',
uid,
@@ -149,6 +181,15 @@ export const AICodingButton: React.FC<AICodingButtonProps> = ({ uid, scene, lang
code: editorRef?.read(),
},
});
if (variablesDesc) {
addContextItems({
type: 'text',
uid: 'available-variables',
title: 'Available variables',
content: `You can access the following variables via context:\n${variablesDesc}`,
});
}
}}
/>
</Popover>
@@ -333,7 +333,7 @@ export const ErrorMessage: React.FC<{
});
export const HintMessage: React.FC<{ msg: any }> = memo(({ msg }) => {
return <Alert message={<>{msg.content} </>} type="info" showIcon closable />;
return <Alert style={{ marginBottom: 8 }} message={<>{msg.content} </>} type="info" showIcon closable />;
});
export const TaskMessage: React.FC<{
@@ -93,6 +93,8 @@
"Get code snippet list": "Get code snippet list",
"Get collection metadata": "Get collection metadata",
"Get collection names": "Get collection names",
"Get data sources": "Get data sources",
"Retrieve list of all available data sources": "Retrieve list of all available data sources",
"Get models list failed, you can enter a model name manually.": "Get models list failed, you can enter a model name manually.",
"Greeting message": "Greeting message",
"Greeting message placeholder": "Opening message sent to the user when starting a new conversation.",
@@ -129,9 +131,9 @@
"Parameter name": "Parameter name",
"Parameter type": "Parameter type",
"Parameters": "Parameters",
"Characterization": "Characterization",
"Characterization description": "The system prompt for the AI model, defines who \"I\" am, as well as the rules and requirements I follow to perform tasks.",
"Characterization placeholder": "The system prompt for the AI model, defines who \"I\" am, as well as the rules and requirements I follow to perform tasks.",
"Role setting": "Role setting",
"Role setting description": "The system prompt for the AI model, defines who \"I\" am, as well as the rules and requirements I follow to perform tasks.",
"Role setting placeholder": "The system prompt for the AI model, defines who \"I\" am, as well as the rules and requirements I follow to perform tasks.",
"Personalized prompt": "Personalized prompt",
"Personalized prompt description": "You can set personalized prompt for the current AI employee which will be sent to the LLM at the start of each new conversation.",
"Pick block": "Pick block",
@@ -204,5 +206,7 @@
"knowledge Base Prompt default": "From knowledge base:\n{knowledgeBaseData}\nAnswer user's question using this information.",
"please fix the error": "please fix the error",
"please review the code": "please review the code",
"references {{index}}": "references {{index}}"
"references {{index}}": "references {{index}}",
"Please complete role setting before submitting": "Please complete role setting before submitting",
"Please complete model setting before submitting": "Please complete model setting before submitting"
}
@@ -93,6 +93,8 @@
"Get code snippet list": "获取代码片段列表",
"Get collection metadata": "获取数据表元数据",
"Get collection names": "获取数据表名称",
"Get data sources": "获取数据源",
"Retrieve list of all available data sources": "获取所有可用数据源列表",
"Get models list failed, you can enter a model name manually.": "获取模型列表失败,你可以手动输入模型名称。",
"Greeting message": "问候语",
"Greeting message placeholder": "开启新对话时发送给用户的开场白",
@@ -129,9 +131,9 @@
"Parameter name": "参数名",
"Parameter type": "参数类型",
"Parameters": "参数",
"Characterization": "人物设定",
"Characterization description": "AI模型的系统提示词,决定了“我”是谁,遵循哪些要求来工作和完成任务。",
"Characterization placeholder": "AI模型的系统提示词,决定了“我”是谁,遵循哪些要求来工作和完成任务。",
"Role setting": "人物设定",
"Role setting description": "AI 模型的系统提示词,决定了“我”是谁,遵循哪些要求来工作和完成任务。",
"Role setting placeholder": "AI 模型的系统提示词,决定了“我”是谁,遵循哪些要求来工作和完成任务。",
"Personalized prompt": "个性化提示词",
"Personalized prompt description": "你可以针对当前 AI 员工设置个性化的提示词,将在每轮新对话时发送给大模型",
"Pick block": "选择区块",
@@ -204,5 +206,7 @@
"knowledge Base Prompt default": "从知识库检索到的已知信息如下:\n{knowledgeBaseData}\n请参考上述已知信回答用户提问。",
"please fix the error": "请修复这个错误",
"please review the code": "请审查这份代码",
"references {{index}}": "参考资料 {{index}}"
"references {{index}}": "参考资料 {{index}}",
"Please complete role setting before submitting": "请先完成人物设定",
"Please complete model setting before submitting": "请先完成模型设置"
}
@@ -13,7 +13,7 @@ import { LLMProvider } from '../llm-providers/provider';
import { Database } from '@nocobase/database';
import { concat } from '@langchain/core/utils/stream';
import PluginAIServer from '../plugin';
import { parseVariables } from '../utils';
import { sendSSEError, parseVariables } from '../utils';
import { getSystemPrompt } from './prompts';
import _ from 'lodash';
import { AIChatContext, AIChatConversation, AIMessage, AIMessageInput } from '../types';
@@ -173,12 +173,17 @@ export class AIEmployee {
this.plugin.aiEmployeesManager.conversationController.delete(this.sessionId);
// 如果流式过程中发生错误,必须发送错误事件,无论是否有部分内容
if (errMsg) {
this.sendErrorResponse(errMsg);
return;
}
const message = gathered?.content;
const toolCalls = gathered?.tool_calls;
const skills = this.employee.skillSettings?.skills;
if (!message && !toolCalls?.length && !signal.aborted && !allowEmpty) {
this.ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: errMsg })}\n\n`);
this.ctx.res.end();
this.sendErrorResponse(errMsg);
return;
}
@@ -271,7 +276,8 @@ export class AIEmployee {
return result;
}
getDataSources() {
// Notice: employee.dataSourceSettings is not used in the current version.
getEmployeeDataSourceContext() {
const dataSourceSettings: {
collections?: {
collection: string;
@@ -330,7 +336,7 @@ export class AIEmployee {
});
let systemMessage = await parseVariables(this.ctx, this.employee.about);
const dataSourceMessage = this.getDataSources();
const dataSourceMessage = this.getEmployeeDataSourceContext();
if (dataSourceMessage) {
systemMessage = `${systemMessage}\n${dataSourceMessage}`;
}
@@ -583,7 +589,7 @@ export class AIEmployee {
}
} catch (err) {
this.ctx.log.error(err);
this.sendErrorResponse('Tool call error');
this.sendErrorResponse(err.message || 'Tool call error');
}
}
@@ -753,8 +759,7 @@ export class AIEmployee {
}
sendErrorResponse(errorMessage: string) {
this.ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: errorMessage })} \n\n`);
this.ctx.res.end();
sendSSEError(this.ctx, errorMessage);
}
async processMessages(userMessages: AIMessageInput[], messageId?: string) {
@@ -15,6 +15,10 @@ export default {
profile,
skillSettings: {
skills: [
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -119,9 +119,13 @@ ctx.render(
```
### 4. Popup Environment
- **Accessing Records**: When running in a popup (e.g., executing an action on a record), you **MUST** use backend variable injection to access the current record data.
- **Correct**: `const popupRecord = {{ ctx.popup.record }};`
- **Incorrect**: `const popupRecord = ctx.popup.record;` (This will be undefined at runtime).
- **Popup Record Access**: When running inside a popup (e.g., detail/edit popup or record action popup), access the current record via backend variable injection.
- **SQL / Expression usage (no quotes)**: Use `{{ ctx.popup.record }}` directly (it will be injected by the backend as a real value/object at runtime).
- **Example (expression)**: `{{ ctx.popup.record }}`
- **Example (SQL placeholder)**: `{{ ctx.popup.record.id }}` (or other fields) where your SQL/template system supports variable injection.
- **JavaScript / JSX usage (runtime code)**: In JS/JSX, retrieve the popup record via `ctx.resolveJsonTemplate()`.
- **Example**: `const popupRecord = await ctx.resolveJsonTemplate('{{ ctx.popup.record }}');`
- **Note**: This is the reliable way to read injected popup variables in runtime code.
### 5. Implementation Constraints
1. **Single-file constraint**
@@ -172,13 +176,6 @@ Don't add headings like "Summary:" or "Update:".
- The conversation history may refer to tools that are no longer available. NEVER call tools that are not explicitly provided.
- After you decide to call a tool, include the tool call information and parameters in your response, and I will run the tool for you and provide you with tool call results.
### Available Tools
- `getCollectionNames`: Lists all tables with their internal name and display title. Use this to disambiguate user references.
- `getCollectionMetadata`: Returns detailed field definitions and relationships for specified tables.
- `listCodeSnippet`: list builtin code snippets which can be used to implement the task.
- `getCodeSnippet`: use ref from "listCodeSnippet" to get code snippets content.
## Code Style
IMPORTANT: The code you write will be reviewed by humans; optimize for clarity and readability. Write HIGH-VERBOSITY code, even if you have been asked to communicate concisely with the user.
@@ -19,6 +19,10 @@ export default {
name: 'dataModeling-intentRouter',
autoCall: true,
},
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -19,6 +19,10 @@ export default {
name: 'frontend-formFiller',
autoCall: true,
},
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -22,6 +22,7 @@ export default {
{ name: 'dataSource-dataSourceCounting', autoCall: true },
{ name: 'dataSource-dataSourceQuery', autoCall: true },
// 了解数据结构
{ name: 'dataModeling-getDataSources', autoCall: true },
{ name: 'dataModeling-getCollectionNames', autoCall: true },
{ name: 'dataModeling-getCollectionMetadata', autoCall: true },
],
@@ -15,7 +15,7 @@ Answer questions using data by fetching required information, analyzing results,
**YOUR PROCESS:**
1. Understand the users intent and the required data.
2. Produce a single sql code block using safe, read-only SELECT to fetch the data. Use tools like getCollectionNames/getCollectionMetadata only to inspect schema (collections and fields). Do not use dataSourceQuery for aggregate or computed expressions; it is for plain column selection only. Always wait for data before continuing.
2. Produce a single sql code block using safe, read-only SELECT to fetch the data. Use tools like getCollectionNames/getCollectionMetadata only to inspect schema (collections and fields).
3. Analyze the data to answer the question without fabricating any content.
4. Visualize the answer:
- Trends/Comparisons/Distributions: use charts (bar/line/pie/etc.)
@@ -24,6 +24,8 @@ Answer questions using data by fetching required information, analyzing results,
**CRITICAL RULES:**
- Language: Respond in the users language: {{$nLang}}.
- SQL Dialect Awareness: Adjust SQL syntax based on the target data source type (e.g., use backticks \` for MySQL/MariaDB, double quotes " for PostgreSQL/SQLite). Check the "type" field in data source information (from context or tool results) before writing SQL.
- DataSource Specification: When writing SQL, ALWAYS add a comment on the first line specifying the data source key, e.g., \`-- dataSource: ExternalMySQL\`. If it's the main database, use \`-- dataSource: main\`.
- Visual-first: Prefer charts or KPI cards whenever possible.
- Data integrity: NEVER fabricate data; if missing, ask one focused question.
- SQL safety: ONLY use SELECT; never INSERT/UPDATE/DELETE.
@@ -1,29 +0,0 @@
/**
* 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 profile from './profile';
export default {
username: 'avery',
description: 'AI employee for form filling',
profile,
skillSettings: {
skills: [
{ name: 'frontend-formFiller', autoCall: true },
{
name: 'dataSource-dataSourceCounting',
autoCall: true,
},
{
name: 'dataSource-dataSourceQuery',
autoCall: true,
},
],
},
};
@@ -1,29 +0,0 @@
/**
* 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 prompt from './prompt';
export default {
'en-US': {
avatar: 'nocobase-045-female',
nickname: 'Avery',
position: 'Form filler',
bio: 'I specialize in extracting structured fields from unstructured input and completing forms quickly and accurately. Your reliable partner in form handling.',
greeting: 'Hi, Im Avery. Send me the form and the content youd like filled in—Ill take care of the rest.',
about: prompt['en-US'],
},
'zh-CN': {
avatar: 'nocobase-045-female',
nickname: 'Avery',
position: '表单助理',
bio: '我擅长从非结构化输入中提取结构化字段,并快速准确地完成表单填写。我是您处理表单时的可靠伙伴。',
greeting: '嗨,我是 Avery。请给我表单和您想填写的内容,我负责处理。',
about: prompt['en-US'],
},
};
@@ -1,17 +0,0 @@
/**
* 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 {
'en-US': `You are Avery, a professional and reliable form assistant. The user will provide a form definition (with field definitions) and unstructured content to be filled. Your tasks:
1. Parse the form definition to identify the fields;
2. Extract corresponding values from the content;
3. Build a structured data object in JSON;
4. Call formFiller tool to fill in the form;
5. If user requests for generating data for multiple forms, the response should be separated.Unless an error occurs or the user asks for explanation, keep your response natural, focused, and execution-oriented.`,
};
@@ -15,6 +15,10 @@ export default {
profile,
skillSettings: {
skills: [
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -15,6 +15,10 @@ export default {
profile,
skillSettings: {
skills: [
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -19,6 +19,10 @@ export default {
name: 'frontend-formFiller',
autoCall: true,
},
{
name: 'dataModeling-getDataSources',
autoCall: true,
},
{
name: 'dataModeling-getCollectionNames',
autoCall: true,
@@ -96,10 +96,12 @@ class AIChatConversationImpl implements AIChatConversation {
$lt: query.messageId,
};
}
return await this.aiConversationMessagesRepo.find({
sort: ['messageId'],
const messages = await this.aiConversationMessagesRepo.find({
sort: ['-messageId'], // 改为倒序,取最新的
limit: 50, // 新增:最多 50 条消息
filter,
});
return messages.reverse(); // 反转回正序
}
async lastUserMessage(): Promise<AIMessage> {
@@ -133,12 +135,20 @@ class AIChatConversationImpl implements AIChatConversation {
const formattedMessages = [];
const { provider, workContextHandler } = options;
// 新增:截断过长的内容
const truncate = (text: string, maxLen = 50000) => {
if (!text || text.length <= maxLen) return text;
return text.slice(0, maxLen) + '\n...[truncated]';
};
for (const msg of messages) {
const attachments = msg.attachments;
const workContext = msg.workContext;
let content = msg.content?.content;
if (!content && !attachments && !msg.toolCalls?.length) {
continue;
// 新增:截断消息内容
if (typeof content === 'string') {
content = truncate(content);
}
if (msg.role === 'user') {
if (typeof content === 'string') {
@@ -9,7 +9,6 @@
import PluginAIServer from '../plugin';
import dataModeling from '../ai-employees/built-in/data-modeling';
import formFiller from '../ai-employees/built-in/form-filler';
import aiCoding from '../ai-employees/built-in/ai-coding';
import dataOrganizer from '../ai-employees/built-in/data-organizer';
import insightsAnalyst from '../ai-employees/built-in/insights-analyst';
@@ -33,7 +32,6 @@ const DEFAULT_KNOWLEDGE_BASE_PROMPT =
export class BuiltInManager {
private builtInEmployees = [
dataModeling,
formFiller,
aiCoding,
dataOrganizer,
insightsAnalyst,
@@ -29,6 +29,7 @@ import {
formFiller,
getCollectionMetadata,
getCollectionNames,
getDataSources,
getWorkflowCallers,
chartGenerator,
} from './tools';
@@ -56,6 +57,15 @@ export class PluginAIServer extends Plugin {
workContextHandler = createWorkContextHandler(this);
snowflake: Snowflake;
/**
* Check if the AI employee is a builder/admin-only type (e.g., Nathan, Orin).
* These employees have powerful capabilities (coding, schema modification) and should be restricted to admins.
*/
isBuilderAI(username: string) {
const BUILDER_AI_USERNAMES = ['nathan', 'orin', 'dara'];
return BUILDER_AI_USERNAMES.includes(username);
}
async afterAdd() {}
async beforeLoad() {
@@ -133,6 +143,10 @@ export class PluginAIServer extends Plugin {
groupName: dataModelingGroupName,
tool: dataModelingIntentRouter,
},
{
groupName: dataModelingGroupName,
tool: getDataSources,
},
{
groupName: dataModelingGroupName,
tool: getCollectionNames,
@@ -228,8 +242,21 @@ export class PluginAIServer extends Plugin {
}
this.app.db.on('roles.beforeCreate', async (instance: Model) => {
instance.set('allowNewAiEmployee', ['admin', 'member'].includes(instance.name));
instance.set('allowNewAiEmployee', true);
});
// 为新角色默认开启 AI 员工
this.app.db.on('roles.afterCreate', async (instance: Model, { transaction }) => {
const allAiEmployees = await this.app.db.getRepository('aiEmployees').find({
transaction,
});
const generalAiEmployees = allAiEmployees.filter((ai: { username: string }) => !this.isBuilderAI(ai.username));
if (generalAiEmployees.length > 0) {
await instance.addAiEmployees(generalAiEmployees, { transaction });
}
});
this.app.db.on('aiEmployees.afterCreate', async (instance: Model, { transaction }) => {
const roles = await this.app.db.getRepository('roles').find({
filter: {
@@ -238,9 +265,15 @@ export class PluginAIServer extends Plugin {
transaction,
});
// 为初始化/新创建的 AI 员工分配角色,builder 类的AI员工默认只对 Admin 角色开启
let targetRoles = roles;
if (this.isBuilderAI(instance.username)) {
targetRoles = roles.filter((role: { name: string }) => role.name === 'admin');
}
// @ts-ignore
await this.app.db.getRepository('aiEmployees.roles', instance.username).add({
tk: roles.map((role: { name: string }) => role.name),
tk: targetRoles.map((role: { name: string }) => role.name),
transaction,
});
});
@@ -10,7 +10,7 @@
import actions, { Context, Next } from '@nocobase/actions';
import PluginAIServer from '../plugin';
import { Model } from '@nocobase/database';
import { parseResponseMessage } from '../utils';
import { parseResponseMessage, sendSSEError } from '../utils';
import { AIEmployee } from '../ai-employees/ai-employee';
async function getAIEmployee(ctx: Context, username: string) {
@@ -36,8 +36,7 @@ function setupSSEHeaders(ctx: Context) {
}
function sendErrorResponse(ctx: Context, errorMessage: string) {
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body: errorMessage })} \n\n`);
ctx.res.end();
sendSSEError(ctx, errorMessage);
}
export default {
@@ -357,7 +356,7 @@ export default {
await aiEmployee.processMessages(messages, editingMessageId);
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, err.message || 'Chat error warning');
sendErrorResponse(ctx, err.message || 'Tool call error');
}
await next();
@@ -421,7 +420,7 @@ export default {
await aiEmployee.resendMessages(messageId);
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, 'Chat error warning');
sendErrorResponse(ctx, err.message || 'Chat error warning');
}
await next();
@@ -525,7 +524,7 @@ export default {
await aiEmployee.callTool(message.messageId, false);
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, 'Tool call error');
sendErrorResponse(ctx, err.message || 'Tool call error');
}
await next();
},
@@ -584,7 +583,7 @@ export default {
await aiEmployee.confirmToolCall(message.messageId, toolCallIds);
} catch (err) {
ctx.log.error(err);
sendErrorResponse(ctx, 'Tool call confirm error');
sendErrorResponse(ctx, err.message || 'Tool call confirm error');
}
await next();
},
@@ -129,14 +129,25 @@ export const getCollectionNames: ToolOptions = {
description: '{{t("Retrieve names and titles map of all collections")}}',
schema: {
type: 'object',
properties: {},
properties: {
dataSource: {
type: 'string',
description: 'The data source name to retrieve collections from. Defaults to "main".',
},
},
additionalProperties: false,
},
invoke: async (ctx: Context) => {
invoke: async (ctx: Context, args: { dataSource?: string }) => {
const { dataSource = 'main' } = args || {};
let names: { name: string; title: string }[] = [];
try {
const collections = await ctx.db.getRepository('collections').find();
names = collections.map((collection: { name: string; title: string }) => ({
const ds = ctx.app.dataSourceManager.dataSources.get(dataSource);
if (!ds) {
throw new Error(`Data source "${dataSource}" not found`);
}
const collections = ds.collectionManager.getCollections();
names = collections.map((collection) => ({
name: collection.name,
title: collection.title,
}));
@@ -152,7 +163,6 @@ export const getCollectionNames: ToolOptions = {
content: `Failed to retrieve collection names: ${err.message}`,
};
}
return {
status: 'success',
content: JSON.stringify(names),
@@ -167,6 +177,10 @@ export const getCollectionMetadata: ToolOptions = {
schema: {
type: 'object',
properties: {
dataSource: {
type: 'string',
description: 'The data source name. Defaults to "main".',
},
collectionNames: {
type: 'array',
items: {
@@ -181,39 +195,92 @@ export const getCollectionMetadata: ToolOptions = {
invoke: async (
ctx: Context,
args: {
dataSource?: string;
collectionNames: string[];
},
) => {
const { collectionNames } = args || {};
const { collectionNames, dataSource = 'main' } = args || {};
if (!collectionNames || !Array.isArray(collectionNames) || collectionNames.length === 0) {
return {
status: 'error',
content: 'No collection names provided or invalid format.',
};
}
const collections = await ctx.db.getRepository('collections').find({
filter: { name: collectionNames },
appends: ['fields'],
});
const metadata = collections.map((collection: any) => {
const fields = collection.fields.map((field: any) => {
try {
const ds = ctx.app.dataSourceManager.dataSources.get(dataSource);
if (!ds) {
return {
name: field.name,
type: field.type,
interface: field.interface,
options: field.options || {},
status: 'error',
content: `Data source "${dataSource}" not found`,
};
});
}
const metadata = [];
for (const name of collectionNames) {
const collection = ds.collectionManager.getCollection(name);
if (!collection) continue;
const fields = collection.getFields().map((field) => {
return {
name: field.name,
type: field.type,
interface: field.options.interface,
options: field.options || {},
};
});
metadata.push({
name: collection.name,
title: collection.title,
fields,
});
}
return {
name: collection.name,
title: collection.title,
fields,
status: 'success',
content: JSON.stringify(metadata),
};
});
return {
status: 'success',
content: JSON.stringify(metadata),
};
} catch (err) {
return {
status: 'error',
content: `Failed to retrieve metadata: ${err.message}`,
};
}
},
};
export const getDataSources: ToolOptions = {
name: 'getDataSources',
title: '{{t("Get data sources")}}',
description: '{{t("Retrieve list of all available data sources")}}',
schema: {
type: 'object',
properties: {},
additionalProperties: false,
},
invoke: async (ctx: Context) => {
try {
const records = await ctx.db.getRepository('dataSources').find();
const displayNameMap = new Map(records.map((r) => [r.get('key'), r.get('displayName')]));
const dataSources = [];
// Add data sources
for (const [key, ds] of ctx.app.dataSourceManager.dataSources) {
dataSources.push({
key: key,
displayName: displayNameMap.get(key) || key,
type: ds.collectionManager?.db?.sequelize?.getDialect() || 'unknown',
});
}
return {
status: 'success',
content: JSON.stringify(dataSources),
};
} catch (err) {
return {
status: 'error',
content: `Failed to retrieve data sources: ${err.message}`,
};
}
},
};
@@ -14,6 +14,20 @@ import axios from 'axios';
import { getDateVars, parse, parseFilter } from '@nocobase/utils';
import { Context } from '@nocobase/actions';
export function sendSSEError(ctx: Context, error: Error | string) {
const body = typeof error === 'string' ? error : error.message || 'Unknown error';
if (!ctx.res.headersSent) {
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
ctx.status = 200;
}
ctx.res.write(`data: ${JSON.stringify({ type: 'error', body })}\n\n`);
ctx.res.end();
}
export function stripToolCallTags(content: string): string | null {
if (typeof content !== 'string') {
return content;
@@ -68,7 +68,12 @@ export const DaraButton: React.FC<{ ctx: FlowSettingsContext<any> }> = ({ ctx })
) && !/return\s*\{/.test(content);
const isEvents = /chart\.(on|off)\s*\(|ctx\.\w+\s*\(/.test(content) && !/\breturn\s*\{/.test(content);
if (isSql) return ctx.writeSql(content);
if (isSql) {
// 从注释中提取数据源
const dsMatch = content.match(/^--\s*dataSource:\s*(\S+)/i);
const dataSource = dsMatch ? dsMatch[1] : undefined;
return ctx.writeSql(content, dataSource);
}
if (isEvents) return ctx.writeChartEvents(content);
return ctx.writeChartConfig(content);
} catch (e) {
@@ -38,8 +38,8 @@ export const ConfigPanel: React.FC = () => {
};
useEffect(() => {
ctx?.defineMethod?.('writeSql', async (sql: string) => {
const dsKey = form?.values?.query?.sqlDatasource ?? DEFAULT_DATA_SOURCE_KEY;
ctx?.defineMethod?.('writeSql', async (sql: string, dataSource?: string) => {
const dsKey = dataSource || form?.values?.query?.sqlDatasource || DEFAULT_DATA_SOURCE_KEY;
form?.setValuesIn?.('query.mode', 'sql');
form?.setValuesIn?.('query.sql', sql);
form?.setValuesIn?.('query.sqlDatasource', dsKey);
@@ -89,11 +89,11 @@
"Please click 'Run Query' to fetch data before configuring chart options": "请先点击”运行查询“获取数据,再配置图表选项",
"Please configure and run query": "请配置并执行数据查询",
"Please configure chart": "请配置图表",
"Please run query to retrive data.": "请行查询来获取数据。",
"Please run query to retrive data.": "请行查询来获取数据。",
"Please select a chart type.": "请选择图表类型",
"Query": "查询",
"Recommend chart type by data structure and explain reasons": "请根据数据结构推荐图表类型并解释原因",
"Run query": "行查询",
"Run query": "行查询",
"Same properties set in the form above will be overwritten by this JSON config.": "上面表单中设置的相同属性将被JSON配置覆盖。",
"Scatter": "散点图",
"Select Field": "选择字段",