feat: implement business node specifications and enhance workflow editor with node specs support
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
import { canContainNode } from '../../utils';
|
||||
import { FlowNodeRegistry } from '../../typings';
|
||||
import { nodeRegistries } from '../../nodes';
|
||||
import { getActiveNodeRegistries } from '../../nodes';
|
||||
|
||||
const NodeWrap = styled.div`
|
||||
width: 100%;
|
||||
@@ -72,7 +72,7 @@ interface NodeListProps {
|
||||
}
|
||||
|
||||
export const NodeList: FC<NodeListProps> = (props) => {
|
||||
const { onSelect, containerNode, fromPort } = props;
|
||||
const { onSelect, containerNode } = props;
|
||||
const context = useClientContext();
|
||||
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
|
||||
const json = registry.onAdd?.(context);
|
||||
@@ -82,10 +82,9 @@ export const NodeList: FC<NodeListProps> = (props) => {
|
||||
nodeJSON: json,
|
||||
});
|
||||
};
|
||||
console.log('>>> fromNode', fromPort?.node);
|
||||
return (
|
||||
<NodesWrap style={{ width: 80 * 2 + 20 }}>
|
||||
{nodeRegistries
|
||||
{getActiveNodeRegistries()
|
||||
.filter((register) => register.meta.nodePanelVisible !== false)
|
||||
.filter((register) => {
|
||||
if (register.meta.onlyInContainer) {
|
||||
@@ -107,7 +106,7 @@ export const NodeList: FC<NodeListProps> = (props) => {
|
||||
icon={
|
||||
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
|
||||
}
|
||||
label={registry.type as string}
|
||||
label={registry.info?.title || (registry.type as string)}
|
||||
onClick={(e) => handleClick(e, registry)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,10 +10,16 @@ import { EditorRenderer, FreeLayoutEditorProvider } from '@flowgram.ai/free-layo
|
||||
|
||||
import '@flowgram.ai/free-layout-editor/index.css';
|
||||
import './styles/index.css';
|
||||
import { nodeRegistries } from './nodes';
|
||||
import type { FlowDocumentJSON } from './typings';
|
||||
import {
|
||||
createBusinessNodeRegistries,
|
||||
enrichDocumentWithNodeSpecs,
|
||||
nodeRegistries,
|
||||
setActiveNodeRegistries,
|
||||
type WorkflowNodeSpec,
|
||||
} from './nodes';
|
||||
import { initialData } from './initial-data';
|
||||
import { useEditorProps } from './hooks';
|
||||
import type { FlowDocumentJSON } from './typings';
|
||||
|
||||
const MESSAGE_SOURCE = 'agent-desk';
|
||||
|
||||
@@ -22,13 +28,16 @@ type LoadMessage = {
|
||||
type: 'workflow:load';
|
||||
documentKey: string;
|
||||
document: FlowDocumentJSON;
|
||||
nodeSpecs?: WorkflowNodeSpec[];
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export const Editor = () => {
|
||||
const [documentKey, setDocumentKey] = useState('official-default');
|
||||
const [documentRevision, setDocumentRevision] = useState(0);
|
||||
const [document, setDocument] = useState<FlowDocumentJSON>(initialData);
|
||||
const [readonly, setReadonly] = useState(false);
|
||||
const [registries, setRegistries] = useState(nodeRegistries);
|
||||
const handleDocumentChange = useCallback((nextDocument: FlowDocumentJSON) => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
@@ -39,7 +48,7 @@ export const Editor = () => {
|
||||
window.location.origin
|
||||
);
|
||||
}, []);
|
||||
const editorProps = useEditorProps(document, nodeRegistries, handleDocumentChange, readonly);
|
||||
const editorProps = useEditorProps(document, registries, handleDocumentChange, readonly);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<LoadMessage>) => {
|
||||
@@ -50,8 +59,21 @@ export const Editor = () => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nodeSpecs = event.data.nodeSpecs ?? [];
|
||||
const executableTypes = new Set(
|
||||
nodeSpecs.filter((spec) => spec.executable).map((spec) => spec.type)
|
||||
);
|
||||
const builtInRegistries =
|
||||
executableTypes.size > 0
|
||||
? nodeRegistries.filter((registry) => executableTypes.has(registry.type as string))
|
||||
: nodeRegistries;
|
||||
const businessRegistries = createBusinessNodeRegistries(nodeSpecs);
|
||||
const nextRegistries = [...builtInRegistries, ...businessRegistries];
|
||||
setDocumentKey(event.data.documentKey);
|
||||
setDocument(event.data.document);
|
||||
setDocumentRevision((revision) => revision + 1);
|
||||
setActiveNodeRegistries(nextRegistries);
|
||||
setRegistries(nextRegistries);
|
||||
setDocument(enrichDocumentWithNodeSpecs(event.data.document, nodeSpecs));
|
||||
setReadonly(Boolean(event.data.readonly));
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
@@ -64,7 +86,10 @@ export const Editor = () => {
|
||||
|
||||
return (
|
||||
<div className="doc-free-feature-overview">
|
||||
<FreeLayoutEditorProvider key={`${documentKey}-${readonly}`} {...editorProps}>
|
||||
<FreeLayoutEditorProvider
|
||||
key={`${documentKey}-${documentRevision}-${readonly}`}
|
||||
{...editorProps}
|
||||
>
|
||||
<div className="demo-container">
|
||||
<DockedPanelLayer>
|
||||
<EditorRenderer className="demo-editor" />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { IFlowValue } from '@flowgram.ai/form-materials';
|
||||
|
||||
import type { FlowDocumentJSON, FlowNodeJSON, FlowNodeRegistry } from '../typings';
|
||||
import iconVariable from '../assets/icon-variable.png';
|
||||
|
||||
export type WorkflowVariableSpec = {
|
||||
name: string;
|
||||
label?: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type WorkflowNodeSpec = {
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
executable: boolean;
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
interruptible: boolean;
|
||||
requiresConfirmationPredecessor: boolean;
|
||||
inputSchema?: WorkflowVariableSpec[];
|
||||
outputSchema?: WorkflowVariableSpec[];
|
||||
defaultInputs?: Record<string, IFlowValue>;
|
||||
};
|
||||
|
||||
const builtInNodeTypes = new Set(['start', 'end', 'llm', 'condition']);
|
||||
|
||||
function schemaType(type: string): string {
|
||||
switch (type) {
|
||||
case 'integer':
|
||||
return 'number';
|
||||
case 'array<string>':
|
||||
case 'array<int>':
|
||||
case 'array<object>':
|
||||
return 'array';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSchema(variables: WorkflowVariableSpec[] | undefined) {
|
||||
const properties = Object.fromEntries(
|
||||
(variables ?? []).map((variable) => [
|
||||
variable.name,
|
||||
{
|
||||
type: schemaType(variable.type),
|
||||
title: variable.label || variable.name,
|
||||
description: variable.description,
|
||||
extra: variable.type === 'string' ? { formComponent: 'prompt-editor' } : undefined,
|
||||
},
|
||||
])
|
||||
);
|
||||
return {
|
||||
type: 'object' as const,
|
||||
required: (variables ?? []).filter((item) => item.required).map((item) => item.name),
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBusinessNodeRegistries(specs: WorkflowNodeSpec[]): FlowNodeRegistry[] {
|
||||
return specs
|
||||
.filter((spec) => spec.executable && !builtInNodeTypes.has(spec.type))
|
||||
.map((spec) => ({
|
||||
type: spec.type,
|
||||
info: { icon: iconVariable, title: spec.title, description: spec.description },
|
||||
meta: {
|
||||
defaultPorts: [{ type: 'input' }, { type: 'output' }],
|
||||
size: { width: 360, height: 280 },
|
||||
},
|
||||
onAdd() {
|
||||
return {
|
||||
id: `${spec.type}_${nanoid(5)}`,
|
||||
type: spec.type,
|
||||
data: {
|
||||
title: spec.title,
|
||||
inputsValues: structuredClone(spec.defaultInputs ?? {}),
|
||||
inputs: buildSchema(spec.inputSchema),
|
||||
outputs: buildSchema(spec.outputSchema),
|
||||
nodeSpec: {
|
||||
category: spec.category,
|
||||
riskLevel: spec.riskLevel,
|
||||
interruptible: spec.interruptible,
|
||||
requiresConfirmationPredecessor: spec.requiresConfirmationPredecessor,
|
||||
},
|
||||
},
|
||||
} as FlowNodeJSON;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function enrichDocumentWithNodeSpecs(
|
||||
document: FlowDocumentJSON,
|
||||
specs: WorkflowNodeSpec[]
|
||||
): FlowDocumentJSON {
|
||||
const specsByType = new Map(specs.map((spec) => [spec.type, spec]));
|
||||
const conditionNodeIDs = new Set(
|
||||
document.nodes
|
||||
.filter((node) => node.type === 'condition' && Array.isArray(node.data.config?.branches))
|
||||
.map((node) => node.id)
|
||||
);
|
||||
return {
|
||||
...document,
|
||||
edges: document.edges.map((edge) =>
|
||||
conditionNodeIDs.has(edge.sourceNodeID) && edge.sourcePortID === 'default'
|
||||
? { ...edge, sourcePortID: 'else' }
|
||||
: edge
|
||||
),
|
||||
nodes: document.nodes.map((node) => {
|
||||
const spec = specsByType.get(node.type as string);
|
||||
if (!spec) return node;
|
||||
const legacyBranches = Array.isArray(node.data.config?.branches)
|
||||
? node.data.config.branches
|
||||
: [];
|
||||
const conditions = legacyBranches
|
||||
.filter((branch: any) => !branch.default && branch.condition)
|
||||
.map((branch: any) => ({
|
||||
key: branch.id,
|
||||
value: {
|
||||
left: branch.condition.left,
|
||||
operator: branch.condition.operator,
|
||||
right: { type: 'constant', content: branch.condition.right },
|
||||
},
|
||||
}));
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
title: node.data.title || spec.title,
|
||||
inputsValues: node.data.inputsValues ?? structuredClone(spec.defaultInputs ?? {}),
|
||||
inputs: node.data.inputs ?? buildSchema(spec.inputSchema),
|
||||
outputs: node.data.outputs ?? buildSchema(spec.outputSchema),
|
||||
nodeSpec: {
|
||||
category: spec.category,
|
||||
riskLevel: spec.riskLevel,
|
||||
interruptible: spec.interruptible,
|
||||
requiresConfirmationPredecessor: spec.requiresConfirmationPredecessor,
|
||||
},
|
||||
...(node.type === 'condition' && conditions.length > 0 ? { conditions } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { BlockStartNodeRegistry } from './block-start';
|
||||
import { BlockEndNodeRegistry } from './block-end';
|
||||
import { MultiConditionNodeRegistry } from "./multi-condition";
|
||||
export { WorkflowNodeType } from './constants';
|
||||
export { createBusinessNodeRegistries, enrichDocumentWithNodeSpecs } from './business';
|
||||
export type { WorkflowNodeSpec } from './business';
|
||||
|
||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
ConditionNodeRegistry,
|
||||
@@ -43,3 +45,13 @@ export const nodeRegistries: FlowNodeRegistry[] = [
|
||||
GroupNodeRegistry,
|
||||
MultiConditionNodeRegistry,
|
||||
];
|
||||
|
||||
let activeNodeRegistries = nodeRegistries;
|
||||
|
||||
export function setActiveNodeRegistries(registries: FlowNodeRegistry[]) {
|
||||
activeNodeRegistries = registries;
|
||||
}
|
||||
|
||||
export function getActiveNodeRegistries() {
|
||||
return activeNodeRegistries;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface FlowNodeRegistry extends FlowNodeRegistryDefault {
|
||||
info?: {
|
||||
icon: string;
|
||||
description: string;
|
||||
title?: string;
|
||||
};
|
||||
canAdd?: (ctx: FreeLayoutPluginContext) => boolean;
|
||||
canDelete?: (ctx: FreeLayoutPluginContext, from: FlowNodeEntity) => boolean;
|
||||
|
||||
Reference in New Issue
Block a user