feat: data modeling

This commit is contained in:
xilesun 2025-06-25 10:35:24 +08:00
parent af37305134
commit 1573ef3b82
13 changed files with 1169 additions and 124 deletions

View File

@ -1,106 +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 { Form, GeneralField, isVoidField } from '@formily/core';
import { RenderPropsChildren, SchemaComponentsContext } from '@formily/react';
import { toJS } from '@formily/reactive';
import { observer } from '@formily/reactive-react';
import { FormPath, isFn } from '@formily/shared';
import React, { Fragment, useContext } from 'react';
interface IReactiveFieldProps {
field: GeneralField;
children?: RenderPropsChildren<GeneralField>;
}
const mergeChildren = (children: RenderPropsChildren<GeneralField>, content: React.ReactNode) => {
if (!children && !content) return;
if (isFn(children)) return;
return (
<Fragment>
{children}
{content}
</Fragment>
);
};
const isValidComponent = (target: any) => target && (typeof target === 'object' || typeof target === 'function');
const renderChildren = (children: RenderPropsChildren<GeneralField>, field?: GeneralField, form?: Form) =>
isFn(children) ? children(field, form) : children;
const ReactiveInternal: React.FC<IReactiveFieldProps> = (props) => {
const components = useContext(SchemaComponentsContext);
if (!props.field) {
return <Fragment>{renderChildren(props.children)}</Fragment>;
}
const field = props.field;
const content = mergeChildren(
renderChildren(props.children, field, field.form),
field.content ?? field.componentProps.children,
);
if (field.display !== 'visible') return null;
const getComponent = (target: any) => {
return isValidComponent(target) ? target : FormPath.getIn(components, target) ?? target;
};
const renderDecorator = (children: React.ReactNode) => {
if (!field.decoratorType) {
return <Fragment>{children}</Fragment>;
}
return React.createElement(getComponent(field.decoratorType), toJS(field.decoratorProps), children);
};
const renderComponent = () => {
if (!field.componentType) return content;
const value = !isVoidField(field) ? field.value : undefined;
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args);
field.componentProps?.onChange?.(...args);
}
: field.componentProps?.onChange;
const onFocus = !isVoidField(field)
? (...args: any[]) => {
field.onFocus(...args);
field.componentProps?.onFocus?.(...args);
}
: field.componentProps?.onFocus;
const onBlur = !isVoidField(field)
? (...args: any[]) => {
field.onBlur(...args);
field.componentProps?.onBlur?.(...args);
}
: field.componentProps?.onBlur;
const disabled = !isVoidField(field) ? field.pattern === 'disabled' || field.pattern === 'readPretty' : undefined;
const readOnly = !isVoidField(field) ? field.pattern === 'readOnly' : undefined;
return React.createElement(
getComponent(field.componentType),
{
disabled,
readOnly,
...toJS(field.componentProps),
value,
onChange,
onFocus,
onBlur,
},
content,
);
};
return renderDecorator(renderComponent());
};
ReactiveInternal.displayName = 'ReactiveField';
export const ReactiveField = observer(ReactiveInternal, {
forwardRef: true,
});

View File

@ -1,8 +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.
*/

View File

@ -27,6 +27,8 @@
"echarts-for-react": "3.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"@xyflow/react": "^12.7.0",
"elkjs": "^0.10.0"
}
}

View File

@ -31,7 +31,6 @@ export function replaceTagBlockByIndex(
indexToReplace: number,
newInnerStr: string,
): string {
console.log(input, tagName, indexToReplace, newInnerStr);
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'gi');
let currentIndex = 0;
return input.replace(regex, (match, inner) => {
@ -100,9 +99,7 @@ const EditModal: React.FC<{
form.reset();
}}
onOk={async () => {
console.log(option);
const content = replaceTagBlockByIndex(message.content, 'echarts', index, JSON.stringify(option));
console.log(content);
await updateMessage({
sessionId: currentConversation,
messageId: message.messageId,
@ -128,7 +125,7 @@ const EditModal: React.FC<{
marginTop: '16px',
}}
>
<Col span={12}>
<Col span={8}>
<SchemaComponent
schema={{
type: 'void',
@ -158,7 +155,7 @@ const EditModal: React.FC<{
}}
/>
</Col>
<Col span={12}>
<Col span={16}>
{option ? (
<Card size="small">
<ReactECharts

View File

@ -17,6 +17,7 @@ import { Message } from '../../types';
import { Code } from './Code';
import { Echarts } from './ECharts';
import { Form } from './Form';
import { Collections } from '../../data-modeling/Collections';
export const Markdown: React.FC<{
message: Message;
@ -43,6 +44,9 @@ export const Markdown: React.FC<{
echarts: (props) => {
return <Echarts {...props} index={getIndex('echarts')} message={message} />;
},
collections: (props) => {
return <Collections {...props} message={message} />;
},
}}
rehypePlugins={[
rehypeRaw,
@ -50,7 +54,7 @@ export const Markdown: React.FC<{
rehypeSanitize,
{
...defaultSchema,
tagNames: [...defaultSchema.tagNames, 'echarts', 'form'],
tagNames: [...defaultSchema.tagNames, 'echarts', 'form', 'collections'],
attributes: {
...defaultSchema.attributes,
form: ['uid', 'datasource', 'collection'],

View File

@ -0,0 +1,145 @@
/**
* 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 { Handle, Position } from 'reactflow';
import { List, Card, Flex } from 'antd';
import { useApp, useToken } from '@nocobase/client';
import { useT } from '../../locale';
import { Schema } from '@formily/react';
import { KeyOutlined } from '@ant-design/icons';
export const CollectionNode = ({ data }) => {
const { token } = useToken();
const app = useApp();
const t = useT();
const fim = app.dataSourceManager.collectionFieldInterfaceManager;
const handleStyle = {
top: '50%',
transform: 'translateY(-50%)',
width: 8,
height: 8,
opacity: 0,
pointerEvents: 'none' as const,
};
return (
<Card
size="small"
title={
<>
{data.title}{' '}
<span
style={{
fontSize: token.fontSizeSM,
color: token.colorTextDescription,
}}
>
{data.name}
</span>
</>
}
styles={{
body: {
padding: 0,
minWidth: '200px',
},
}}
>
<List
dataSource={data.fields}
size="small"
renderItem={(item: any) => {
const fieldInterface = fim.getFieldInterface(item.interface);
return (
<List.Item
style={{
position: 'relative', // 为了定位 Handle
}}
>
<Flex
justify="space-between"
gap="large"
style={{
width: '100%',
}}
>
<Handle
type="source"
position={Position.Right}
id={`${data.name}-${item.name}-source-right`}
style={handleStyle}
/>
<Handle
type="source"
position={Position.Left}
id={`${data.name}-${item.name}-source-left`}
style={handleStyle}
/>
<Handle
type="target"
position={Position.Left}
id={`${data.name}-${item.name}-target-left`}
style={handleStyle}
/>
<Handle
type="target"
position={Position.Right}
id={`${data.name}-${item.name}-target-right`}
style={handleStyle}
/>
<Flex vertical={true}>
<div>
{item.title}
{item.primaryKey ? (
<KeyOutlined
style={{
marginLeft: '4px',
}}
/>
) : (
''
)}
</div>
<div
style={{
fontSize: token.fontSizeSM,
color: token.colorTextDescription,
}}
>
{item.name}
</div>
</Flex>
<Flex vertical={true} align="flex-end">
<div
style={{
fontSize: token.fontSizeSM,
color: token.colorTextSecondary,
}}
>
{fieldInterface?.title ? Schema.compile(fieldInterface.title, { t }) : ''}
</div>
<div
style={{
fontSize: token.fontSizeSM,
color: token.colorTextDescription,
}}
>
{item.type}
</div>
</Flex>
</Flex>
</List.Item>
);
}}
/>
</Card>
);
};

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, { useEffect, useRef } from 'react';
import { Generating } from '../chatbox/markdown/Generating';
import { useChatMessages } from '../chatbox/ChatMessagesProvider';
import mermaid from 'mermaid';
import { Card, Modal, Tabs } from 'antd';
import { DatabaseOutlined, FileTextOutlined, NodeIndexOutlined } from '@ant-design/icons';
import { useT } from '../../locale';
import { CodeInternal } from '../chatbox/markdown/Code';
import { Diagram } from './Diagram';
import { Table } from './Table';
import { useAPIClient, useApp } from '@nocobase/client';
mermaid.initialize({
startOnLoad: true,
});
const Mermaid: React.FC<{ diagram: string }> = ({ diagram }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
try {
const id = 'mermaid-' + Math.random().toString(36).slice(2);
mermaid.render(id, diagram, (svgCode) => {
ref.current!.innerHTML = svgCode;
});
} catch (err) {
console.error(err);
console.log(diagram);
}
}
}, [diagram]);
return <div ref={ref} className="mermaid" />;
};
const TabPane: React.FC = ({ children }) => {
return (
<div
style={{
height: '70vh',
overflowY: 'auto',
}}
>
{children}
</div>
);
};
export const DataModelingModal: React.FC<{
open: boolean;
setOpen: (open: boolean) => void;
collections: any[];
}> = ({ open, setOpen, collections }) => {
const t = useT();
const api = useAPIClient();
const app = useApp();
const fim = app.dataSourceManager.collectionFieldInterfaceManager;
const items = [
{
key: 'collections',
label: t('Collections'),
icon: <DatabaseOutlined />,
children: <Table collections={collections} />,
},
{
key: 'graph',
icon: <NodeIndexOutlined />,
label: t('Diagram'),
children: (
<TabPane>
<Diagram collections={collections} />
</TabPane>
),
},
{
key: 'definition',
icon: <FileTextOutlined />,
label: 'Definition',
children: (
<TabPane>
<CodeInternal language="json" value={JSON.stringify(collections, null, 2)} />
</TabPane>
),
},
];
return (
<Modal
open={open}
width="90%"
onCancel={() => {
setOpen(false);
}}
okText={t('Finish review and apply')}
onOk={async () => {
const result = [];
for (const collection of collections) {
const fields = collection.fields.map((field: any) => {
const fieldInterface = fim.getFieldInterface(field.interface);
if (fieldInterface) {
field.type = fieldInterface.default?.type || field.type;
field.uiSchema = fieldInterface.default?.uiSchema || field.uiSchema;
}
field.uiSchema = {
...field.uiSchema,
title: field.title,
};
if (field.enum) {
field.uiSchema = {
...field.uiSchema,
enum: field.enum,
};
}
return field;
});
result.push({
...collection,
fields,
});
}
await api.resource('ai').defineCollections({
values: {
collections,
},
});
setOpen(false);
}}
>
<Tabs defaultActiveKey="1" items={items} />
</Modal>
);
};
export const Collections = (props: any) => {
const t = useT();
const [open, setOpen] = React.useState(false);
const { children, className, message, index, ...rest } = props;
const { responseLoading } = useChatMessages();
if (responseLoading && !message.messageId) {
return <Generating />;
}
const collectionsStr = String(children).replace(/\n$/, '');
const collections = JSON.parse(collectionsStr);
return (
<>
<Card
style={{
marginBottom: '16px',
cursor: 'pointer',
}}
onClick={() => setOpen(true)}
>
<Card.Meta
avatar={<DatabaseOutlined />}
title={t('Data modeling')}
description={t('Please review and finish the process')}
/>
</Card>
<DataModelingModal open={open} setOpen={setOpen} collections={collections} />
</>
);
};

View File

@ -0,0 +1,263 @@
/**
* 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, { useEffect } from 'react';
import ReactFlow, {
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
Handle,
Position,
Edge,
Node,
} from 'reactflow';
import ELK from 'elkjs/lib/elk.bundled.js';
import '@xyflow/react/dist/style.css';
import { CollectionNode } from './CollectionNode';
import { css } from '@emotion/css';
import { useGlobalTheme } from '@nocobase/client';
const elk = new ELK();
const nodeTypes = {
collection: CollectionNode,
};
const layoutElements = async (nodes: Node[], edges: Edge[]) => {
const isHorizontal = !edges.length;
const graph = {
id: 'root',
layoutOptions: {
'elk.algorithm': edges.length ? 'org.eclipse.elk.layered' : 'org.eclipse.elk.box',
// 'elk.spacing.nodeNode': '100',
// 'elk.direction': isHorizontal ? 'RIGHT' : 'DOWN',
// 'elk.expandNodes': true,
},
children: nodes.map((node) => ({
...node,
// Adjust the target and source handle positions based on the layout
// direction.
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
// Hardcode a width and height for elk to use when layouting.
width: 320,
height: 50 * (node.data.fields.length + 1),
})),
edges: edges,
};
return elk
.layout(graph)
.then((layoutedGraph) => ({
nodes: layoutedGraph.children.map((node) => ({
...node,
// React Flow expects a position property on the node instead of `x`
// and `y` fields.
position: { x: node.x, y: node.y },
})),
edges: layoutedGraph.edges,
}))
.catch(console.error);
};
const getDiagramData = (collections: any[]) => {
const collectionMap = new Map(collections.map((col) => [col.name, col]));
for (const collection of collections) {
if (collection.autoGenId) {
collection.fields.unshift({
name: 'id',
interface: 'id',
type: 'bigInt',
title: 'ID',
primaryKey: true,
autoIncrement: true,
});
}
}
const nodes = collections.map((collection) => ({
id: collection.name,
type: 'collection',
position: { x: 0, y: 0 },
data: {
title: collection.title,
name: collection.name,
fields: [...collection.fields],
},
}));
const edges: any[] = [];
// for (const collection of collections) {
// for (const field of collection.fields) {
// const { type, target, name: fieldName, foreignKey, targetKey, through } = field;
//
// if (!target || !collectionMap.has(target)) continue;
// const targetCollection = collectionMap.get(target);
// const targetFields = targetCollection.fields || [];
// const targetFieldExists = (key: string) => targetFields.some((f: any) => f.name === key);
//
// const tk = targetKey || 'id';
// const fk = foreignKey || 'id';
//
// if (type === 'belongsTo' && target === collection.name) {
// if (!targetFieldExists(tk)) continue;
//
// edges.push({
// id: `${collection.name}-${fieldName}-to-self`,
// source: collection.name,
// sourceHandle: `${collection.name}-${fieldName}-source-right`,
// target: collection.name,
// targetHandle: `${collection.name}-${tk}-target-right`,
// type: 'smoothstep',
// animated: true,
// });
// continue;
// }
//
// if (type === 'belongsTo') {
// if (!targetFieldExists(tk)) continue;
//
// edges.push({
// id: `${collection.name}-${fieldName}-to-${tk}`,
// source: collection.name,
// sourceHandle: `${collection.name}-${fieldName}-source-right`,
// target,
// targetHandle: `${target}-${tk}-target-left`,
// // type: 'smoothstep',
// animated: true,
// });
// continue;
// }
//
// if (type === 'hasMany' && target === collection.name) {
// if (!targetFieldExists(tk)) continue;
//
// edges.push({
// id: `${collection.name}-${tk}-to-${fieldName}`,
// source: target,
// sourceHandle: `${collection.name}-${tk}-source-left`,
// target: collection.name,
// targetHandle: `${collection.name}-${fieldName}-target-left`,
// type: 'smoothstep',
// animated: true,
// });
// continue;
// }
//
// if (type === 'hasMany') {
// if (!targetFieldExists(tk)) continue;
//
// edges.push({
// id: `${collection.name}-${tk}-to-${fieldName}`,
// source: target,
// sourceHandle: `${target}-${tk}-source-right`,
// target: collection.name,
// targetHandle: `${collection.name}-${fieldName}-target-left`,
// // type: 'smoothstep',
// animated: true,
// });
// continue;
// }
//
// if (type === 'hasOne') {
// if (!targetFieldExists(fk)) continue;
//
// edges.push({
// id: `${collection.name}-${fieldName}-to-${fk}`,
// source: collection.name,
// sourceHandle: `${collection.name}-${fieldName}-source-right`,
// target,
// targetHandle: `${target}-${fk}-target-left`,
// // type: 'smoothstep',
// animated: true,
// });
// continue;
// }
//
// // if (type === 'belongsToMany') {
// // let label = '';
// // if (through && collectionMap.has(through)) {
// // const throughCollection = collectionMap.get(through);
// // const throughFields = throughCollection.fields || [];
// // const hasCurrent = throughFields.some((f: any) => f.target === collection.name);
// // const hasTarget = throughFields.some((f: any) => f.target === target);
// //
// // if (!hasCurrent || !hasTarget) continue;
// // label = `via ${through}`;
// // } else {
// // label = through ? `via ${through}` : 'via ?';
// // }
// //
// // edges.push({
// // id: `${collection.name}-${fieldName}-to-${target}`,
// // source: collection.name,
// // sourceHandle: `${collection.name}-${fieldName}-source`,
// // target,
// // targetHandle: `${target}-${fieldName}-target`,
// // type: 'smoothstep',
// // animated: true,
// // label,
// // });
// // }
// }
// }
return layoutElements(nodes, edges);
};
export const Diagram: React.FC<{
collections: any[];
}> = ({ collections }) => {
const { isDarkTheme } = useGlobalTheme();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
useEffect(() => {
getDiagramData(collections)
.then(({ nodes, edges }) => {
setNodes(nodes);
setEdges(edges);
})
.catch((error) => console.error('Error laying out elements:', error));
}, [collections]);
return (
<div
className={css`
width: 100%;
height: 100%;
svg:not(:root) {
overflow: unset;
}
`}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
panOnDrag={true}
zoomOnScroll={true}
proOptions={{ hideAttribution: true }}
onNodesChange={onNodesChange}
// @ts-ignore
colorMode={isDarkTheme ? 'dark' : 'light'}
>
<Background />
<Controls position="top-right" />
</ReactFlow>
</div>
);
};

View File

@ -0,0 +1,161 @@
/**
* 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 { Table as AntdTable, Checkbox, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import { useT } from '../../locale';
import { useApp } from '@nocobase/client';
import { Schema } from '@formily/react';
import lodash from 'lodash';
const useColumns = () => {
const t = useT();
const columns: TableColumnsType = [
{ title: t('Collection display name'), dataIndex: 'title', key: 'title', width: 200 },
{ title: t('Collection name'), dataIndex: 'name', key: 'name', width: 150 },
{
title: t('Collection template'),
dataIndex: 'template',
key: 'template',
width: 150,
render: (value) => {
const template = value.charAt(0).toUpperCase() + value.slice(1);
return <Tag>{t(`${template} collection`)}</Tag>;
},
},
{
title: t('Description'),
dataIndex: 'description',
key: 'description',
width: 350,
},
{
title: t('Preset fields'),
key: 'preset',
width: 300,
render: (_, record) => {
const value = [];
if (record.autoGenId !== false) {
value.push('id');
}
if (record.createdAt !== false) {
value.push('createdAt');
}
if (record.updatedAt !== false) {
value.push('updatedAt');
}
if (record.createdBy) {
value.push('createdBy');
}
if (record.updatedBy) {
value.push('updatedBy');
}
return (
<Checkbox.Group
options={[
{
label: 'ID',
value: 'id',
},
{
label: t('Created at'),
value: 'createdAt',
},
{
label: t('Last Updated at'),
value: 'updatedAt',
},
{
label: t('Created by'),
value: 'createdBy',
},
{
label: t('Last updated by'),
value: 'updatedBy',
},
]}
defaultValue={value}
disabled
/>
);
},
},
];
return columns;
};
const useExpandColumns = () => {
const t = useT();
const app = useApp();
const fim = app.dataSourceManager.collectionFieldInterfaceManager;
const columns = [
{
title: t('Field display name'),
dataIndex: 'title',
key: 'title',
},
{
title: t('Field name'),
dataIndex: 'name',
key: 'name',
},
{
title: t('Field interface'),
dataIndex: 'interface',
key: 'interface',
render: (value) => {
const fieldInterface = fim.getFieldInterface(value);
return <Tag>{fieldInterface ? Schema.compile(fieldInterface.title, { t }) : value}</Tag>;
},
},
{
title: t('Description'),
dataIndex: 'description',
key: 'description',
},
{
title: t('Options'),
key: 'options',
render: (_, record) => {
return JSON.stringify(lodash.omit(record, ['title', 'name', 'interface', 'description']), null, 2);
},
},
];
return columns;
};
const ExpandedRowRender = (record) => {
const expandColumns = useExpandColumns();
return <AntdTable rowKey="name" columns={expandColumns} dataSource={record.fields} pagination={false} />;
};
export const Table: React.FC<{
collections: any[];
}> = ({ collections }) => {
const columns = useColumns();
return (
<AntdTable
scroll={{
y: '60vh',
}}
style={{
height: '70vh',
}}
rowKey="name"
columns={columns}
dataSource={collections}
expandable={{
expandedRowRender: ExpandedRowRender,
rowExpandable: (record) => record.fields && record.fields.length > 0,
}}
/>
);
};

View File

@ -0,0 +1,251 @@
/**
* 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 interface CollectionOptions extends Omit<SequelizeModelOptions, 'name' | 'hooks'> {
/** The unique identifier of the collection, must be unique across the database */
name: string;
/** The display title of the collection, used for UI presentation */
title?: string;
/** The description of the collection */
description?: string;
/** Whether this collection is a through table for many-to-many relationships */
isThrough?: boolean;
/** The target key(s) used for filtering operations, can be a single key or array of keys */
filterTargetKey?: string | string[];
/** Array of field definitions for the collection */
fields?: FieldOptions[];
/**
* Whether to automatically generate an 'id' field
* @default true
*/
autoGenId?: boolean;
/**
* Whether to automatically generate a 'createdAt' timestamp field
* @default true
*/
createdAt?: boolean;
/** Whether to automatically generate an 'updatedAt' timestamp field
* @default true
*/
updatedAt?: boolean;
/**
* Whether to automatically generate a 'createdById' field for record ownership
* @default false
*/
createdBy?: boolean;
/**
* Whether to automatically generate an 'updatedById' field for tracking updates
* @default false
*/
updatedBy?: boolean;
/** The template identifier used to create this collection */
template: 'general' | 'tree' | 'file' | 'calendar' | 'expression';
/** The field name used for tree structure functionality */
tree?: 'adjacencyList';
}
export type FieldOptions =
| BaseFieldOptions
| StringFieldOptions
| IntegerFieldOptions
| FloatFieldOptions
| DecimalFieldOptions
| DoubleFieldOptions
| JsonFieldOptions
| JsonbFieldOptions
| BooleanFieldOptions
| RadioFieldOptions
| TextFieldOptions
| TimeFieldOptions
| DateFieldOptions
| DatetimeTzFieldOptions
| DatetimeNoTzFieldOptions
| DateOnlyFieldOptions
| UnixTimestampFieldOptions
| UidFieldOptions
| UUIDFieldOptions
| NanoidFieldOptions
| PasswordFieldOptions
| BelongsToFieldOptions
| HasOneFieldOptions
| HasManyFieldOptions
| BelongsToManyFieldOptions;
/**
* Base options for all field types
* Provides common properties that are available to all field configurations
*/
export interface BaseFieldOptions {
/** The name of the field, used as the column name in the database */
name: string;
/** The title of the field, used for display in the UI */
title: string;
/** The description of the field */
description?: string;
/** Whether the field should be hidden from API responses and UI */
hidden?: boolean;
/** Required. The user interface component type for this field */
interface:
| 'id'
| 'input'
| 'integer'
| 'checkbox'
| 'checkboxGroup'
| 'color'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'date'
| 'datetime'
| 'datetimeNoTz'
| 'email'
| 'icon'
| 'json'
| 'markdown'
| 'multipleSelect'
| 'nanoid'
| 'number'
| 'password'
| 'percent'
| 'phone'
| 'radioGroup'
| 'richText'
| 'select'
| 'textarea'
| 'time'
| 'unixTimestamp'
| 'url'
| 'uuid'
| 'm2m'
| 'm2o'
| 'o2m'
| 'o2o';
/** enumeration options for the field, used for select/radio/checkbox interfaces */
enum?: {
label: string;
value: string | number | boolean;
}[];
/** Additional properties for extensibility */
[key: string]: any;
}
/**
* Base options for column-based field types
* Extends BaseFieldOptions and includes Sequelize column-specific options
* Excludes the 'type' property as it's handled by the specific field implementations
*/
export interface BaseColumnFieldOptions extends BaseFieldOptions, Omit<ModelAttributeColumnOptions, 'type'> {
/** The Sequelize data type for the column */
dataType?: DataType;
/** Index configuration for the column, can be boolean or detailed index options */
index?: boolean | ModelIndexesOptions;
}
export interface StringFieldOptions extends BaseColumnFieldOptions {
type: 'string';
length?: number;
trim?: boolean;
}
export interface IntegerFieldOptions extends BaseColumnFieldOptions {
type: 'integer';
}
export interface FloatFieldOptions extends BaseColumnFieldOptions {
type: 'float';
}
export interface DecimalFieldOptions extends BaseColumnFieldOptions {
type: 'decimal';
precision: number;
scale: number;
}
export interface DoubleFieldOptions extends BaseColumnFieldOptions {
type: 'double';
}
export interface JsonFieldOptions extends BaseColumnFieldOptions {
type: 'json';
}
export interface JsonbFieldOptions extends BaseColumnFieldOptions {
type: 'jsonb';
}
export interface BooleanFieldOptions extends BaseColumnFieldOptions {
type: 'boolean';
}
export interface RadioFieldOptions extends BaseColumnFieldOptions {
type: 'radio';
}
export interface TextFieldOptions extends BaseColumnFieldOptions {
type: 'text';
length?: 'tiny' | 'medium' | 'long';
trim?: boolean;
}
export interface TimeFieldOptions extends BaseColumnFieldOptions {
type: 'time';
}
export interface DateFieldOptions extends BaseColumnFieldOptions {
type: 'date';
}
export interface DatetimeTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeTz';
}
export interface DatetimeNoTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeNoTz';
}
export interface DateOnlyFieldOptions extends BaseColumnFieldOptions {
type: 'dateOnly';
}
export interface UnixTimestampFieldOptions extends BaseColumnFieldOptions {
type: 'unixTimestamp';
}
export interface UidFieldOptions extends BaseColumnFieldOptions {
type: 'uid';
prefix?: string;
pattern?: string;
}
export interface UUIDFieldOptions extends BaseColumnFieldOptions {
type: 'uuid';
autoFill?: boolean;
}
export interface NanoidFieldOptions extends BaseColumnFieldOptions {
type: 'nanoid';
size?: number;
customAlphabet?: string;
autoFill?: boolean;
}
export interface PasswordFieldOptions extends BaseColumnFieldOptions {
type: 'password';
/**
* @default 64
*/
length?: number;
/**
* @default 8
*/
randomBytesSize?: number;
}
export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions {
type: 'belongsTo';
target?: string;
}
export interface HasOneFieldOptions extends BaseRelationFieldOptions, SequelizeHasOneOptions {
type: 'hasOne';
}
export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions {
type: 'hasMany';
target?: string;
}
export interface BelongsToManyFieldOptions
extends MultipleRelationFieldOptions,
Omit<SequelizeBelongsToManyOptions, 'through'> {
type: 'belongsToMany';
target?: string;
through?: string;
throughScope?: AssociationScope;
throughUnique?: boolean;
throughParanoid?: boolean;
}

View File

@ -9,6 +9,7 @@
import { ResourceOptions } from '@nocobase/resourcer';
import { PluginAIServer } from '../plugin';
import _ from 'lodash';
const aiResource: ResourceOptions = {
name: 'ai',
@ -47,6 +48,84 @@ const aiResource: ResourceOptions = {
ctx.body = res.models || [];
return next();
},
defineCollections: async (ctx, next) => {
const { collections } = ctx.action.params.values || {};
const id = {
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
interface: 'integer',
};
const createdAt = {
name: 'createdAt',
interface: 'createdAt',
type: 'date',
field: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
};
const updatedAt = {
type: 'date',
field: 'updatedAt',
name: 'updatedAt',
interface: 'updatedAt',
uiSchema: {
type: 'datetime',
title: '{{t("Last updated at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
};
for (const options of collections) {
if (options.name === 'users') {
continue;
}
if (options.autoGenId !== false) {
options.autoGenId = false;
options.fields.unshift(id);
}
if (options.createdAt !== false) {
options.fields.push(createdAt);
}
if (options.updatedAt !== false) {
options.fields.push(updatedAt);
}
const primaryKey = options.fields.find((field: any) => field.primaryKey);
if (!options.filterTargetKey) {
options.filterTargetKey = primaryKey?.name || 'id';
}
// omit defaultValue
options.fields = options.fields.map((field: any) => {
return _.omit(field, ['defaultValue']);
});
ctx.db.collection(options);
}
for (const options of collections) {
const collection = ctx.db.getCollection(options.name);
await collection.sync();
}
const repo = ctx.db.getRepository('collections');
for (const options of collections) {
await repo.db2cm(options.name);
}
await next();
},
},
};

View File

@ -152,6 +152,36 @@ export const collectionTableSchema: ISchema = {
},
'x-align': 'left',
},
aiEmployee: {
type: 'void',
'x-component': 'AIEmployeeButton',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'aiEmployees:button',
'x-component-props': {
username: 'orin',
tasks: [
{
message: {
workContext: [],
attachments: [],
user: 'Create a todo list application with support for multiple lists, due dates, priorities, and task categories. Include tables for users, lists, tasks, and categories.',
},
title: 'Create a todo list application',
autoSend: true,
},
{
message: {
workContext: [],
attachments: [],
user: 'Create a CRM application that includes the following modules: Leads, Customers, Orders, Products, Tickets',
},
title: 'Create a CRM application',
autoSend: true,
},
],
},
},
delete: {
type: 'void',
title: '{{ t("Delete") }}',

View File

@ -7939,7 +7939,7 @@
resolved "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz#096efdf55eb97480e3f5621ff9a8da552f0961e7"
integrity sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==
"@types/d3-drag@*", "@types/d3-drag@^3.0.1":
"@types/d3-drag@*", "@types/d3-drag@^3.0.1", "@types/d3-drag@^3.0.7":
version "3.0.7"
resolved "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
@ -7985,7 +7985,7 @@
resolved "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz#8d3638df273ec90da34b3ac89d8784c59708cb0d"
integrity sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==
"@types/d3-interpolate@*":
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
@ -8029,6 +8029,11 @@
resolved "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe"
integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==
"@types/d3-selection@^3.0.10":
version "3.0.11"
resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
"@types/d3-shape@*":
version "3.1.6"
resolved "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72"
@ -8063,7 +8068,14 @@
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@*", "@types/d3-zoom@^3.0.1":
"@types/d3-transition@^3.0.8":
version "3.0.9"
resolved "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@*", "@types/d3-zoom@^3.0.1", "@types/d3-zoom@^3.0.8":
version "3.0.8"
resolved "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
@ -9548,6 +9560,30 @@
loupe "^2.3.7"
pretty-format "^29.7.0"
"@xyflow/react@^12.7.0":
version "12.7.0"
resolved "https://registry.npmjs.org/@xyflow/react/-/react-12.7.0.tgz#a963692cf38466ba88d54b8ad9099d5e58034460"
integrity sha512-U6VMEbYjiCg1byHrR7S+b5ZdHTjgCFX4KpBc634G/WtEBUvBLoMQdlCD6uJHqodnOAxpt3+G2wiDeTmXAFJzgQ==
dependencies:
"@xyflow/system" "0.0.62"
classcat "^5.0.3"
zustand "^4.4.0"
"@xyflow/system@0.0.62":
version "0.0.62"
resolved "https://registry.npmjs.org/@xyflow/system/-/system-0.0.62.tgz#3ddb0f549f4ffa81f3924875dce4ddd9ccc9a31d"
integrity sha512-Z2ufbnvuYxIOCGyzE/8eX8TAEM8Lpzc/JafjD1Tzy6ZJs/E7KGVU17Q1F5WDHVW+dbztJAdyXMG0ejR9bwSUAA==
dependencies:
"@types/d3-drag" "^3.0.7"
"@types/d3-interpolate" "^3.0.4"
"@types/d3-selection" "^3.0.10"
"@types/d3-transition" "^3.0.8"
"@types/d3-zoom" "^3.0.8"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.npmmirror.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
@ -14670,6 +14706,11 @@ electron-to-chromium@^1.5.73:
resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz#db20295c5061b68f07c8ea4dfcbd701485d94a3d"
integrity sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==
elkjs@^0.10.0:
version "0.10.0"
resolved "https://registry.npmjs.org/elkjs/-/elkjs-0.10.0.tgz#abe2aa6cb25e7439b708fab873b2448d26ed33a1"
integrity sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==
elkjs@^0.8.2:
version "0.8.2"
resolved "https://registry.npmmirror.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e"
@ -30827,6 +30868,11 @@ use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
resolved "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
use-sync-external-store@^1.2.2:
version "1.5.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
use@^3.1.0:
version "3.1.1"
resolved "https://registry.npmmirror.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@ -31967,6 +32013,13 @@ zrender@5.6.1:
dependencies:
tslib "2.3.0"
zustand@^4.4.0:
version "4.5.7"
resolved "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zustand@^4.4.1:
version "4.4.7"
resolved "https://registry.npmmirror.com/zustand/-/zustand-4.4.7.tgz#355406be6b11ab335f59a66d2cf9815e8f24038c"