feat: form filler 2.0

This commit is contained in:
xilesun
2025-07-22 09:30:20 +08:00
parent 8a7af18b6d
commit 5bc5cb3db2
16 changed files with 410 additions and 305 deletions
@@ -23,7 +23,7 @@ import { buildSubModelGroups, buildSubModelItems } from './utils';
export interface SubModelItem {
key?: string;
label?: string;
label?: string | React.ReactNode;
type?: 'group' | 'divider';
disabled?: boolean;
icon?: React.ReactNode;
@@ -7,18 +7,18 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import { FloatButton, Avatar, Dropdown } from 'antd';
import React, { useMemo, useState } from 'react';
import { FloatButton, Avatar, Dropdown, Button, Divider } from 'antd';
import icon from '../icon.png';
import { css } from '@emotion/css';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { AIEmployeeListItem } from '../AIEmployeeListItem';
import { useAISelectionContext } from '../selector/AISelectorProvider';
import { PauseCircleFilled } from '@ant-design/icons';
import { PauseCircleFilled, RightOutlined, LeftOutlined } from '@ant-design/icons';
import { useToken } from '@nocobase/client';
import { FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine';
import { useChatBoxStore } from './stores/chat-box';
import { useChatBoxActions } from './hooks/useChatBoxActions';
import { ShortcutList } from '../shortcuts/ShortcutList';
export const ChatButton: React.FC = () => {
const { aiEmployees } = useAIEmployeesContext();
@@ -30,13 +30,6 @@ export const ChatButton: React.FC = () => {
const { stopSelect, selectable } = useAISelectionContext();
const { token } = useToken();
// const flowEngine = useFlowEngine();
//
// const model = useMemo(() => {
// return flowEngine.createModel({
// use: 'AIEmployeeShortcutListModel',
// });
// }, [flowEngine]);
const items = useMemo(() => {
return aiEmployees?.map((employee) => ({
@@ -58,36 +51,39 @@ export const ChatButton: React.FC = () => {
return (
<div
className={css`
.ant-float-btn {
width: 60px;
height: 60px;
}
.ant-float-btn .ant-float-btn-body .ant-float-btn-content {
padding: 0;
}
.ant-float-btn .ant-float-btn-body .ant-float-btn-content .ant-float-btn-icon {
width: 56px;
height: 56px;
opacity: 0.8;
transition: opacity 0.1s;
}
.ant-float-btn .ant-float-btn-body .ant-float-btn-content .ant-float-btn-icon:hover {
opacity: 1;
}
z-index: 2000;
display: flex;
border-radius: 8px;
gap: 8px;
position: fixed;
bottom: 48px;
align-items: center;
inset-inline-end: 8px;
width: fit-content;
height: 60px;
padding: 4px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
`}
>
{/* <FlowModelRenderer model={model} /> */}
<ShortcutList />
{!selectable ? (
<Dropdown menu={{ items }} placement="topRight">
<FloatButton
icon={<Avatar src={icon} size={56} />}
<Avatar
src={icon}
size={52}
shape="square"
onClick={() => {
setOpen(!open);
}}
/>
</Dropdown>
) : (
<FloatButton
<Button
icon={
<PauseCircleFilled
style={{
@@ -19,10 +19,13 @@ import { parseTask } from '../utils';
import { uid } from '@formily/shared';
import { aiEmployeeRole } from '../roles';
import { useChatToolsStore } from '../stores/chat-tools';
import { useApp } from '@nocobase/client';
export const useChatBoxActions = () => {
const app = useApp();
const t = useT();
const open = useChatBoxStore.use.open();
const setOpen = useChatBoxStore.use.setOpen();
const setSenderValue = useChatBoxStore.use.setSenderValue();
const setTaskVariables = useChatBoxStore.use.setTaskVariables();
@@ -119,7 +122,7 @@ export const useChatBoxActions = () => {
const triggerTask = useCallback(
async (options: TriggerTaskOptions) => {
const { aiEmployee, tasks, variables, localVariables } = options;
const { aiEmployee, tasks } = options;
updateRole(aiEmployee);
if (!open) {
setOpen(true);
@@ -147,11 +150,7 @@ export const useChatBoxActions = () => {
if (tasks.length === 1) {
setMessages(msgs);
const task = tasks[0];
const { userMessage, systemMessage, attachments, workContext } = await parseTask(
task,
variables,
localVariables,
);
const { userMessage, systemMessage, attachments, workContext } = await parseTask(app, task);
if (userMessage && userMessage.type === 'text') {
setSenderValue(userMessage.content);
} else {
@@ -7,8 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useLocalVariables, useVariables } from '@nocobase/client';
import { Application, useLocalVariables, useVariables } from '@nocobase/client';
import { ContextItem } from '../types';
import PluginAIClient from '../..';
async function replaceVariables(template, variables, localVariables = {}) {
const regex = /\{\{\s*(.*?)\s*\}\}/g;
@@ -53,6 +54,7 @@ async function replaceVariables(template, variables, localVariables = {}) {
}
export const parseTask = async (
app: Application,
task: {
message: {
user?: string;
@@ -61,26 +63,23 @@ export const parseTask = async (
workContext?: ContextItem[];
};
},
variables: Record<string, any>,
localVariables?: Record<string, any>,
) => {
let userMessage: any;
const { message } = task;
if (message?.user) {
const content = await replaceVariables(message.user, variables, localVariables);
userMessage = {
type: 'text',
content,
content: message.user,
};
}
let systemMessage: string;
if (message?.system) {
systemMessage = await replaceVariables(message.system, variables, localVariables);
systemMessage = message.system;
}
const attachments = [];
if (message?.attachments?.length) {
for (const attachment of message.attachments) {
const obj = await variables?.parseVariable(attachment, localVariables).then(({ value }) => value);
const obj = attachment;
if (!obj) {
continue;
}
@@ -97,7 +96,27 @@ export const parseTask = async (
}
}
}
return { userMessage, systemMessage, attachments, workContext: message.workContext };
const workContext = [];
if (message.workContext) {
const plugin = app.pm.get('ai') as PluginAIClient;
for (const context of message.workContext) {
if (context.content) {
workContext.push(context);
continue;
}
const contextOptions = plugin.aiManager.workContext.get(context.type);
if (!(contextOptions && contextOptions.getContent)) {
workContext.push(context);
continue;
}
const content = contextOptions.getContent(app, context);
workContext.push({
...context,
content,
});
}
}
return { userMessage, systemMessage, attachments, workContext };
};
const publicPath = window['__nocobase_dev_public_path__'] || window['__nocobase_public_path__'] || '/';
@@ -1,131 +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 React from 'react';
import { WorkContextOptions } from '../types';
import { BuildOutlined, StarOutlined, SelectOutlined, DatabaseOutlined } from '@ant-design/icons';
import { tval } from '@nocobase/utils/client';
// @ts-ignore
import pkg from '../../../../package.json';
import { useT } from '../../locale';
import { useAISelectionContext } from '../selector/AISelectorProvider';
import { useTranslation } from 'react-i18next';
import { Schema } from '@formily/react';
import { Checkbox } from 'antd';
export const CollectionDefinitionsContext: WorkContextOptions = {
menu: {
icon: <StarOutlined />,
label: tval('Modern pages', { ns: pkg.name }),
},
children: {
// form: {
// menu: {
// icon: <FormOutlined />,
// Component: ({ onAdd }) => {
// const t = useT();
// const { t: collectionT } = useTranslation('lm-collections');
// const { startSelect, ctx } = useAISelectionContext();
// return (
// <div
// onClick={() => {
// startSelect('form', {
// onSelect: ({ uid, fieldSchema, collection }) => {
// if (!uid) {
// return;
// }
// let title = '';
// if (collection) {
// title = Schema.compile(collection.title, { t: collectionT });
// }
// onAdd({
// uid,
// title,
// content: JSON.stringify(fieldSchema.toJSON()),
// });
// },
// });
// }}
// >
// {t('Select form')}
// </div>
// );
// },
// },
// tag: {
// Component: ({ item }) => {
// const t = useT();
// return (
// <>
// <FormOutlined /> {t('Form')} ({item.title})
// </>
// );
// },
// },
// actions: [
// {
// responseType: 'json',
// Component: ({ item, message, value }) => {
// const t = useT();
// const { ctx } = useAISelectionContext();
//
// return (
// <Button
// size="small"
// icon={<FormOutlined />}
// variant="link"
// color="primary"
// onClick={() => {
// const content = value || (message.content as unknown as string);
// const data = content.replace('```json', '').replace('```', '');
// const json = JSON.parse(data);
// const form = ctx[item.uid]?.form;
// if (form) {
// form.setValues(json);
// }
// }}
// >
// {t('Fill out form')} ({item.title})
// </Button>
// );
// },
// },
// ],
// },
users: {
menu: {
Component: ({ onAdd }) => {
const t = useT();
const { t: collectionT } = useTranslation('lm-collections');
const { startSelect } = useAISelectionContext();
return <Checkbox>{t('Users #f61f6cda073')}</Checkbox>;
},
},
tag: {
Component: ({ item }) => {
return (
<>
<BuildOutlined /> {item.title}
</>
);
},
},
},
roles: {
menu: {
Component: ({ onAdd }) => {
const t = useT();
const { startSelect } = useAISelectionContext();
return <Checkbox>{t('Roles #4cb2eb66a99')}</Checkbox>;
},
},
},
},
};
@@ -0,0 +1,71 @@
/**
* 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 { WorkContextOptions } from '../types';
import { BuildOutlined, StarOutlined } from '@ant-design/icons';
import { tval } from '@nocobase/utils/client';
// @ts-ignore
import pkg from '../../../../package.json';
import { useFlowEngine } from '@nocobase/flow-engine';
import _ from 'lodash';
function parseModel(node: any) {
if (!node || typeof node !== 'object' || !node.uid || !node.use) return null;
const parsed = {
uid: node.uid,
use: node.use,
props: node.props || {},
decoratorProps: node.decoratorProps || {},
stepParams: node.stepParams || {},
collectionField: node.collectionField
? _.pick(node.collectionField, ['dataSourceKey', 'enum', 'name', 'type', 'title', 'uiSchema'])
: {},
children: {},
};
if (node.subModels && typeof node.subModels === 'object') {
for (const key in node.subModels) {
const group = node.subModels[key];
if (Array.isArray(group)) {
parsed.children[key] = group.map((sub) => parseModel(sub)).filter(Boolean);
} else if (typeof group === 'object') {
const child = parseModel(group);
if (child) {
parsed.children[key] = [child];
}
}
}
}
return parsed;
}
export const FlowModelsContext: WorkContextOptions = {
name: 'flow-model',
tag: {
Component: ({ item }) => {
const flowEngine = useFlowEngine();
const model = flowEngine.getModel(item.uid);
return (
<>
<BuildOutlined /> {model.title}
</>
);
},
},
getContent: (app, { uid }) => {
const model = app.flowEngine.getModel(uid);
if (!model) {
return '';
}
return JSON.stringify(parseModel(model));
},
};
@@ -18,24 +18,13 @@ type AIEmployeeShortcutListModelStructure = {
};
export class AIEmployeeShortcutListModel extends FlowModel<AIEmployeeShortcutListModelStructure> {
// onInit() {
// this.addSubModel('shortcuts', {
// uid: 'data-modeling',
// use: 'AIEmployeeShortcutModel',
// props: {
// avatar: 'notion-3-male',
// },
// });
// }
//
// render() {
// return (
// <div>
// {this.mapSubModels('shortcuts', (shortcut) => {
// console.log(shortcut.props);
// return <FlowModelRenderer key={shortcut.uid} model={shortcut} />;
// })}
// </div>
// );
// }
render() {
return (
<div>
{this.mapSubModels('shortcuts', (shortcut) => {
return <FlowModelRenderer key={shortcut.uid} model={shortcut} />;
})}
</div>
);
}
}
@@ -7,22 +7,67 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Avatar, FloatButton } from 'antd';
import React, { useMemo, useState } from 'react';
import { Avatar, Button, Spin, Popover } from 'antd';
import { FlowModel } from '@nocobase/flow-engine';
import { avatars } from '../../avatars';
import { AIEmployee, TriggerTaskOptions } from '../../types';
import { useAIEmployeesContext } from '../../AIEmployeesProvider';
import { useChatBoxActions } from '../../chatbox/hooks/useChatBoxActions';
import { ProfileCard } from '../../ProfileCard';
const Shortcut: React.FC<TriggerTaskOptions> = ({ aiEmployee: { username }, tasks }) => {
const [focus, setFocus] = useState(false);
const {
aiEmployeesMap,
service: { loading },
} = useAIEmployeesContext();
const aiEmployee = aiEmployeesMap[username];
const { triggerTask } = useChatBoxActions();
const currentAvatar = useMemo(() => {
const avatar = aiEmployee?.avatar;
if (!avatar) {
return null;
}
if (focus) {
return avatars(avatar, {
gesture: ['waveLongArm'],
gestureProbability: 100,
translateX: -15,
});
}
return avatars(avatar);
}, [aiEmployee, focus]);
return (
<Spin spinning={loading}>
<Popover content={<ProfileCard aiEmployee={aiEmployee} />} placement="topLeft">
<Avatar
src={currentAvatar}
size={52}
shape="square"
style={{
cursor: 'pointer',
}}
// @ts-ignore
onMouseEnter={() => setFocus(true)}
onMouseLeave={() => setFocus(false)}
onClick={() => {
triggerTask({ aiEmployee, tasks });
}}
/>
</Popover>
</Spin>
);
};
export class AIEmployeeShortcutModel extends FlowModel {
public declare props: TriggerTaskOptions;
render() {
// const { avatar } = this.props;
// console.log(this.props, this.stepParams, this.getProps());
// return (
// <FloatButton
// icon={<Avatar src={avatars(avatar)} size={56} />}
// style={{
// insetInlineEnd: '94px',
// }}
// />
// );
return <Shortcut {...this.props} />;
}
}
@@ -0,0 +1,28 @@
/**
* 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 { FlowEngine } from '@nocobase/flow-engine';
import { ToolOptions } from '../../../manager/ai-manager';
import { FormModel } from '@nocobase/client';
export const formFillerTool: [string, string, ToolOptions] = [
'frontend',
'formFiller',
{
invoke: (ctx, params) => {
const { form: uid, data } = params;
if (!uid || !data) {
return;
}
const flowEngine = ctx.flowEngine as FlowEngine;
const model = flowEngine.getModel(uid) as FormModel;
model?.form?.setValues(data);
},
},
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -9,6 +9,7 @@
import React, { createContext, useCallback, useContext, useState } from 'react';
import { Selector } from '../types';
import { useFlowEngine } from '@nocobase/flow-engine';
export const AISelectionContext = createContext<{
selectable: string;
@@ -32,9 +33,12 @@ export const AISelectionProvider: React.FC<{
}> = (props) => {
const [selectable, setSelectable] = useState('');
const [selector, setSelector] = useState<Selector>(null);
const flowEngine = useFlowEngine();
const [ctx, setCtx] = useState<{
[key: string]: Record<string, any>;
}>({});
}>({
flowEngine: flowEngine,
});
const startSelect = (selectType: string, selector?: Selector) => {
if (selector) {
@@ -0,0 +1,106 @@
/**
* 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 { Button, Divider } from 'antd';
import { LeftOutlined, RightOutlined, PlusOutlined } from '@ant-design/icons';
import { AddSubModelButton, FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine';
import React, { useMemo, useState } from 'react';
import { useShortcuts } from './useShortcuts';
import { useDesignable, useToken } from '@nocobase/client';
import { useAIEmployeesContext } from '../AIEmployeesProvider';
import { AIEmployeeListItem } from '../AIEmployeeListItem';
export const ShortcutList: React.FC = () => {
const flowEngine = useFlowEngine();
const { designable } = useDesignable();
const { shortcuts, uid, builtIn } = useShortcuts();
const { token } = useToken();
const designMode = designable && !builtIn;
const {
aiEmployees,
service: { loading },
} = useAIEmployeesContext();
const [folded, setFolded] = useState(false);
const model = useMemo(() => {
return flowEngine.createModel({
uid: `ai-shortcuts-${uid}`,
use: 'AIEmployeeShortcutListModel',
subModels: {
shortcuts,
},
});
}, [flowEngine, shortcuts]);
return (
<>
{shortcuts.length > 0 && (
<>
<Button
variant="text"
color="default"
icon={!folded ? <RightOutlined /> : <LeftOutlined />}
style={{
height: '52px',
width: '12px',
fontSize: token.fontSizeSM,
}}
onClick={() => setFolded(!folded)}
/>
{!folded && <FlowModelRenderer model={model} />}
</>
)}
{!builtIn && (
<AddSubModelButton
model={model}
subModelKey={'shortcuts'}
items={async () => {
return loading
? []
: aiEmployees.map((aiEmployee) => ({
key: aiEmployee.username,
label: <AIEmployeeListItem aiEmployee={aiEmployee} />,
createModelOptions: {
use: 'AIEmployeeShortcutModel',
props: {
aiEmployee: {
username: 'form_assistant',
},
},
},
}));
}}
>
<Button
icon={<PlusOutlined />}
variant="dashed"
color="default"
style={{
width: '48px',
height: '48px',
color: 'var(--colorSettings)',
borderColor: 'var(--colorSettings)',
background: 'transparent',
}}
/>
</AddSubModelButton>
)}
{(shortcuts.length || designMode) && (
<Divider
type="vertical"
style={{
height: '50px',
}}
/>
)}
</>
);
};
@@ -0,0 +1,66 @@
/**
* 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 { useParams, useLocation } from 'react-router-dom';
export const useShortcuts = () => {
const { name } = useParams();
const { pathname } = useLocation();
if (pathname === '/admin/settings/data-source-manager/main/collections') {
return {
uid: 'data-modeling',
builtIn: true,
shortcuts: [
{
uid: 'orin',
use: 'AIEmployeeShortcutModel',
props: {
aiEmployee: {
username: 'orin',
},
},
},
],
};
}
if (name === '0krytmcef9g') {
return {
uid: name,
shortcuts: [
{
uid: 'form_assistant',
use: 'AIEmployeeShortcutModel',
props: {
aiEmployee: {
username: 'form_assistant',
},
tasks: [
{
message: {
workContext: [
{
type: 'flow-model',
uid: 'e3b85ce6e70',
title: 'Form(Add new): Users',
},
],
},
},
],
},
},
],
};
}
return {
uid: name,
builtIn: true,
shortcuts: [],
};
};
@@ -8,6 +8,7 @@
*/
import type { BubbleProps } from '@ant-design/x';
import { Application } from '@nocobase/client';
import { ComponentType } from 'react';
export type Selector = {
@@ -37,7 +38,7 @@ export type ContextItem = {
type: string;
uid: string;
title: string;
content: string;
content?: string;
};
export type ToolCall<T> = {
@@ -112,9 +113,7 @@ export type Task = {
export type TriggerTaskOptions = {
aiEmployee?: AIEmployee;
tasks: Task[];
variables?: Record<string, any>;
localVariables?: Record<string, any>;
tasks?: Task[];
};
export type Tool = {
@@ -163,4 +162,5 @@ export type WorkContextOptions = {
};
actions?: ActionOptions[];
children?: Record<string, Omit<WorkContextOptions, 'children'>>;
getContent?: (app: Application, item: ContextItem) => string;
};
@@ -22,6 +22,8 @@ import { PermissionsTab } from './ai-employees/permissions/PermissionsTab';
import { anthropicProviderOptions } from './llm-providers/anthropic';
import { AIEmployeeShortcutListModel, AIEmployeeShortcutModel } from './ai-employees/flow/models';
import { defineCollectionsTool } from './ai-employees/data-modeling/tools';
import { FlowModelsContext } from './ai-employees/context/flow-models';
import { formFillerTool } from './ai-employees/form-filler/tools';
const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider');
const { Employees } = lazy(() => import('./ai-employees/manager/Employees'), 'Employees');
const { LLMServices } = lazy(() => import('./llm-services/LLMServices'), 'LLMServices');
@@ -108,22 +110,9 @@ export class PluginAIClient extends Plugin {
Component: StructuredOutputSettings,
});
// this.aiManager.registerWorkContext('classic-pages', ClassicPagesContext);
// this.aiManager.registerWorkContext('collection-definitions', CollectionDefinitionsContext);
this.aiManager.registerWorkContext('flow-model', FlowModelsContext);
this.aiManager.registerTool(...defineCollectionsTool);
this.aiManager.registerTool('frontEnd', 'formFiller', {
invoke: (ctx, params) => {
const { form: uid, data } = params;
if (!uid || !data) {
return;
}
const form = ctx[uid]?.form;
if (!form) {
return;
}
form.setValues(data);
},
});
this.aiManager.registerTool(...formFillerTool);
}
setupWorkflow() {
@@ -176,82 +176,6 @@ export const collectionTableSchema: ISchema = {
type: 'primary',
},
},
aiEmployee: {
type: 'void',
'x-component': 'AIEmployeeButton',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'aiEmployees:button',
'x-component-props': {
username: 'orin',
tasks: [
{
message: {
workContext: [],
attachments: [],
system: `## Task: Create new schema from scenario
The user will describe a business scenario. Your task is to define the full schema from scratch.
- Confirm all entities and required fields
- Clarify the relationships between entities
- Normalize the structure
- Output a complete <collections> block
Avoid defining system fields already provided by templates.`,
user: 'Start from a business scenario and generate normalized collections',
},
title: 'Start from a business scenario and generate normalized collections',
autoSend: true,
},
{
message: {
workContext: [],
attachments: [],
user: 'Add or adjust fields and relationships in your current database',
system: `## Task: Modify or expand existing collections
The user provides part of the data model and wants to modify it.
- Ask what changes they want: add tables, add fields, update relations, rename, etc.
- Only return the changed parts – do not repeat existing schema
- Wrap new/updated collections in <collections> tags
- Do not remove or rename unless explicitly instructed
Example: Add a new relation to "projects" and a new table:
<collections>
[
{
name: "projects",
fields: [
{
name: "ownerId",
title: "Owner",
type: "belongsTo",
interface: "m2o",
target: "users",
foreignKey: "ownerId",
targetKey: "id"
}
]
},
{
name: "project_tags",
title: "Project Tags",
template: "general",
fields: [
{ name: "name", title: "Name", type: "string", interface: "input" }
]
}
]
</collections>`,
},
title: 'Add or adjust fields and relationships in your current database',
autoSend: true,
},
],
},
},
},
},
[uid()]: {