a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
import { createElement, useMemo } from 'react';
import type { FormDocument, JsonObject } from '@a3s-lab/ui/form/core';
import { createA3SFlowNodeBuildConfig, getA3SFlowCoreNode } from '../integrations/a3s-flow-core';
import {
  A3S_FLOW_V1_COMPATIBILITY,
  type A3SFlowWorkflowDagNode,
} from '../integrations/a3s-flow-dsl-types';
import {
  type A3SFlowDagNodeManifest,
  type A3SFlowDagNodeRegistry,
  a3sFlowDagNodeRegistry,
  mergeA3SFlowDagNodeConfiguration,
  selectA3SFlowDagNodeConfiguration,
} from '../integrations/a3s-flow-node-manifest';
import { isA3SFlowCorePortAvailable } from '../integrations/a3s-flow-validation';
import { a3sFlowDagNodePreviewSummary } from './a3s-flow-node-summary';
import {
  createA3SFlowPanelHostAdapter,
  localizeA3SFlowDagNodeManifest,
} from './a3s-flow-panel-support';
import {
  A3SFlowExpressionVariablesProvider,
  type A3SFlowExpressionVariable,
} from './a3s-flow-variable-picker';
import { a3sFlowWidgetRegistry } from './a3s-flow-widgets';
import {
  WorkflowNodeConfigurationPanel,
  type WorkflowNodeConfigurationPanelProps,
} from './workflow-node-panel';
import { WorkflowNodePreview, type WorkflowNodePreviewProps } from './workflow-node-preview';

export interface A3SFlowDagNodeConfigurationPanelProps
  extends Omit<
    WorkflowNodeConfigurationPanelProps,
    'compatibility' | 'node' | 'onApply' | 'onChange' | 'onReset' | 'value'
  > {
  dagNode: A3SFlowWorkflowDagNode;
  manifest?: A3SFlowDagNodeManifest;
  registry?: A3SFlowDagNodeRegistry;
  connectedOutputPortIds?: readonly string[];
  expressionVariables?: readonly A3SFlowExpressionVariable[];
  onChange: (node: A3SFlowWorkflowDagNode) => void;
  onApply?: (node: A3SFlowWorkflowDagNode, document: FormDocument) => void | Promise<void>;
  onReset?: (node: A3SFlowWorkflowDagNode) => void;
}

export interface A3SFlowDagNodePreviewProps
  extends Omit<WorkflowNodePreviewProps, 'node' | 'ports'> {
  dagNode: A3SFlowWorkflowDagNode;
  manifest?: A3SFlowDagNodeManifest;
  registry?: A3SFlowDagNodeRegistry;
}

function missingManifestAlert(
  type: string,
  locale: string | undefined,
  className: string | undefined,
  surfaceClass: string,
) {
  const chinese = locale?.toLocaleLowerCase().startsWith('zh') === true;
  const copy = chinese
    ? {
        title: `找不到节点定义 ${type}`,
        detail: '请先在节点注册表中添加这个类型。当前节点数据没有被修改。',
      }
    : {
        title: `Unregistered node ${type}`,
        detail: 'Register this type in the node registry. The current node data was not changed.',
      };
  return createElement(
    'section',
    {
      className: [surfaceClass, 'a3s-form-flow-dag-node-missing', className]
        .filter(Boolean)
        .join(' '),
      role: 'alert',
    },
    createElement('strong', null, copy.title),
    createElement('p', null, copy.detail),
  );
}

/** Typed graph preview for one host-owned Flow v1 DAG node. */
export function A3SFlowDagNodePreview({
  className,
  dagNode,
  manifest: suppliedManifest,
  registry = a3sFlowDagNodeRegistry,
  technical = false,
  ...props
}: A3SFlowDagNodePreviewProps) {
  const manifest = suppliedManifest ?? registry.get(dagNode.data.type);
  const coreDefinition = useMemo(
    () => (manifest ? getA3SFlowCoreNode(manifest.type) : undefined),
    [manifest],
  );
  const localizedManifest = useMemo(() => {
    if (!manifest) return undefined;
    const localized = localizeA3SFlowDagNodeManifest(manifest, coreDefinition, props.locale);
    return {
      ...localized,
      display_name:
        typeof dagNode.data.title === 'string' ? dagNode.data.title : localized.display_name,
      description:
        typeof dagNode.data.desc === 'string' ? dagNode.data.desc : localized.description,
    };
  }, [coreDefinition, dagNode.data.desc, dagNode.data.title, manifest, props.locale]);
  const previewPorts = useMemo(() => {
    if (!localizedManifest) return undefined;
    if (!coreDefinition) return localizedManifest.ports;
    return {
      inputs: localizedManifest.ports.inputs,
      outputs: localizedManifest.ports.outputs.filter((port) => {
        const corePort = coreDefinition.ports.outputs.find((candidate) => candidate.id === port.id);
        return !corePort || isA3SFlowCorePortAvailable(corePort, dagNode.data);
      }),
    };
  }, [coreDefinition, dagNode.data, localizedManifest]);
  const previewSummary = useMemo(
    () => a3sFlowDagNodePreviewSummary(dagNode, props.locale),
    [dagNode, props.locale],
  );
  if (!localizedManifest) {
    return missingManifestAlert(
      dagNode.data.type,
      props.locale,
      className,
      'a3s-form-workflow-node-preview',
    );
  }

  return createElement(WorkflowNodePreview, {
    ...props,
    className: ['a3s-form-flow-node-preview', className].filter(Boolean).join(' '),
    node: localizedManifest,
    ports: previewPorts,
    summary: props.summary ?? previewSummary,
    technical,
  });
}

/** Lossless configuration surface for one host-owned Flow v1 DAG node. */
export function A3SFlowDagNodeConfigurationPanel({
  buildConfig,
  className,
  connectedOutputPortIds,
  dagNode,
  expressionVariables,
  fieldVisibility,
  hostAdapter,
  locale,
  manifest: suppliedManifest,
  onApply,
  onChange,
  onReset,
  onRequestConnection,
  registry = a3sFlowDagNodeRegistry,
  widgetRegistry,
  ...props
}: A3SFlowDagNodeConfigurationPanelProps) {
  const manifest = suppliedManifest ?? registry.get(dagNode.data.type);
  const coreDefinition = useMemo(
    () => (manifest ? getA3SFlowCoreNode(manifest.type) : undefined),
    [manifest],
  );
  const localizedManifest = useMemo(
    () => {
      if (!manifest) return undefined;
      const localized = localizeA3SFlowDagNodeManifest(manifest, coreDefinition, locale);
      return {
        ...localized,
        display_name:
          typeof dagNode.data.title === 'string' ? dagNode.data.title : localized.display_name,
        description:
          typeof dagNode.data.desc === 'string' ? dagNode.data.desc : localized.description,
      };
    },
    [coreDefinition, dagNode.data.desc, dagNode.data.title, locale, manifest],
  );
  const value = useMemo(
    () => (manifest ? selectA3SFlowDagNodeConfiguration(dagNode, manifest) : undefined),
    [dagNode, manifest],
  );
  const resolvedFieldVisibility = useMemo(() => {
    if (!manifest || !value) return fieldVisibility;
    return Object.fromEntries(
      manifest.fields.map((field) => {
        const condition = field.visible_when;
        const visible = condition ? value[condition.field] === condition.equals : true;
        return [field.name, fieldVisibility?.[field.name] ?? visible];
      }),
    );
  }, [fieldVisibility, manifest, value]);
  const resolvedBuildConfig = useMemo(
    () =>
      localizedManifest && buildConfig
        ? createA3SFlowNodeBuildConfig(localizedManifest, buildConfig)
        : buildConfig,
    [buildConfig, localizedManifest],
  );
  const resolvedWidgetRegistry = useMemo(
    () => ({
      ...a3sFlowWidgetRegistry,
      ...widgetRegistry,
    }),
    [widgetRegistry],
  );
  const validatingHostAdapter = useMemo(
    () =>
      createA3SFlowPanelHostAdapter({
        connectedOutputPortIds,
        definition: coreDefinition,
        hostAdapter,
        locale,
      }),
    [connectedOutputPortIds, coreDefinition, hostAdapter, locale],
  );

  if (!manifest || !localizedManifest || !value) {
    return missingManifestAlert(
      dagNode.data.type,
      locale,
      className,
      'a3s-form-workflow-node-panel',
    );
  }

  const nextNode = (configuration: JsonObject) =>
    mergeA3SFlowDagNodeConfiguration(dagNode, manifest, configuration);
  const nextPresentation = (patch: JsonObject): A3SFlowWorkflowDagNode => ({
    ...structuredClone(dagNode),
    data: { ...structuredClone(dagNode.data), ...structuredClone(patch), type: dagNode.data.type },
  });

  return createElement(
    A3SFlowExpressionVariablesProvider,
    { variables: expressionVariables },
    createElement(WorkflowNodeConfigurationPanel, {
      ...props,
      buildConfig: resolvedBuildConfig,
      className: ['a3s-form-flow-node-panel', className].filter(Boolean).join(' '),
      compatibility: A3S_FLOW_V1_COMPATIBILITY,
      fieldVisibility: resolvedFieldVisibility,
      hostAdapter: validatingHostAdapter,
      locale,
      node: localizedManifest,
      value,
      onChange: (configuration) => onChange(nextNode(configuration)),
      onApply: async (configuration, document) => onApply?.(nextNode(configuration), document),
      onReset: (configuration) => onReset?.(nextNode(configuration)),
      title: localizedManifest.display_name,
      description: localizedManifest.description,
      onTitleChange: (title) => onChange(nextPresentation({ title })),
      onDescriptionChange: (desc) => onChange(nextPresentation({ desc })),
      onRequestConnection,
      presentation: 'task',
      widgetRegistry: resolvedWidgetRegistry,
    }),
  );
}