feat: implement business node specifications and enhance workflow editor with node specs support

This commit is contained in:
mlogclub
2026-08-16 21:27:45 +08:00
parent 16a0d6f5bd
commit ad971370cf
17 changed files with 397 additions and 22 deletions
@@ -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)}
/>
))}
+30 -5
View File
@@ -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" />
+147
View File
@@ -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 } : {}),
},
};
}),
};
}
+12
View File
@@ -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;
}
+1
View File
@@ -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;