feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { WorkflowMode } from '@coze-workflow/base/api';
export const ICON_URIS = {
[WorkflowMode.Workflow]: 'plugin_icon/workflow.png',
[WorkflowMode.ChatFlow]: 'plugin_icon/chatflow-icon.png',
};

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/use-error-in-catch */
import { get } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { OperateType, workflowApi } from '@coze-workflow/base/api';
import { EncapsulateContext } from '../encapsulate-context';
import {
type EncapsulateWorkflowParams,
type EncapsulateApiService,
} from './types';
import { ICON_URIS } from './constants';
@injectable()
export class EncapsulateApiServiceImpl implements EncapsulateApiService {
@inject(EncapsulateContext)
private encapsulateContext: EncapsulateContext;
async encapsulateWorkflow({
name,
desc,
json,
flowMode,
}: EncapsulateWorkflowParams) {
try {
const res = await workflowApi.EncapsulateWorkflow({
space_id: this.encapsulateContext.spaceId,
name,
flow_mode: flowMode,
desc,
schema: JSON.stringify(json),
icon_uri: ICON_URIS[flowMode],
project_id: this.encapsulateContext.projectId,
});
const workflowId = res.data?.workflow_id;
if (!workflowId) {
return null;
}
return {
workflowId,
};
} catch (e) {
return null;
}
}
async validateWorkflow(json) {
try {
const res = await workflowApi.EncapsulateWorkflow({
space_id: this.encapsulateContext.spaceId,
name: '',
desc: '',
icon_uri: '',
schema: JSON.stringify(json),
only_validate: true,
});
return res?.data?.validate_data || [];
} catch (e) {
return [
{
message: 'call validate api failed',
},
];
}
}
async getWorkflow(spaceId: string, workflowId: string, version?: string) {
let json;
// 有历史版本的场景获取历史版本的数据
if (version) {
const res = await workflowApi.GetHistorySchema({
space_id: spaceId,
workflow_id: workflowId,
workflow_version: version,
commit_id: '',
type: OperateType.DraftOperate,
});
json = get(res, 'data.schema');
} else {
const res = await workflowApi.GetCanvasInfo({
space_id: spaceId,
workflow_id: workflowId,
});
json = get(res, 'data.workflow.schema_json');
}
if (!json) {
return null;
}
return JSON.parse(json);
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
export * from './encapsulate-api-service';

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type ValidateErrorData,
type WorkflowMode,
} from '@coze-workflow/base/';
import { type WorkflowJSON } from '@flowgram-adapter/free-layout-editor';
export interface EncapsulateWorkflowParams {
name: string;
desc: string;
json: WorkflowJSON;
flowMode: WorkflowMode;
}
export interface EncapsulateApiService {
/**
* 封装流程
* @param name
*/
encapsulateWorkflow: (
params: EncapsulateWorkflowParams,
) => Promise<{ workflowId: string } | null>;
/**
* 校验流程
* @param schema
* @returns
*/
validateWorkflow: (json: WorkflowJSON) => Promise<ValidateErrorData[]>;
/**
* 获取流程数据
* @param spaceId
* @param workflowId
* @returns
*/
getWorkflow: (
spaceId: string,
workflowId: string,
version?: string,
) => Promise<WorkflowJSON | null>;
}
export const EncapsulateApiService = Symbol('EncapsulateApiService');

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
definePluginCreator,
type PluginCreator,
} from '@flowgram-adapter/free-layout-editor';
import { WorkflowEncapsulateContainerModule } from './workflow-encapsulate-container-module';
import { EncapsulateValidatorsContainerModule } from './validators';
import { EncapsulateValidateManager } from './validate';
import {
type GetGlobalStateOption,
type GetNodeTemplateOption,
type OnEncapsulateOption,
} from './types';
import { EncapsulateRenderContainerModule } from './render';
import { EncapsulateContext } from './encapsulate-context';
import { EncapsulateManager, EncapsulateService } from './encapsulate';
interface EncapsulatePluginOptions {
getNodeTemplate?: GetNodeTemplateOption;
getGlobalState?: GetGlobalStateOption;
onEncapsulate?: OnEncapsulateOption;
}
export const createWorkflowEncapsulatePlugin: PluginCreator<EncapsulatePluginOptions> =
definePluginCreator<EncapsulatePluginOptions>({
onInit(ctx, options) {
ctx.get<EncapsulateManager>(EncapsulateManager).init();
ctx.get<EncapsulateContext>(EncapsulateContext).setPluginContext(ctx);
if (options.getNodeTemplate) {
ctx
.get<EncapsulateContext>(EncapsulateContext)
.setGetNodeTemplate(options.getNodeTemplate);
}
if (options.getGlobalState) {
ctx
.get<EncapsulateContext>(EncapsulateContext)
.setGetGlobalState(options.getGlobalState);
}
if (options.onEncapsulate) {
ctx.get<EncapsulateService>(EncapsulateService).onEncapsulate(res => {
options?.onEncapsulate?.(res, ctx);
});
}
},
onDispose(ctx) {
ctx.get<EncapsulateValidateManager>(EncapsulateValidateManager).dispose();
ctx.get<EncapsulateService>(EncapsulateService).dispose();
ctx.get<EncapsulateManager>(EncapsulateManager).dispose();
},
containerModules: [
WorkflowEncapsulateContainerModule,
EncapsulateRenderContainerModule,
EncapsulateValidatorsContainerModule,
],
});

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { type StandardNodeType } from '@coze-workflow/base/types';
import { WorkflowMode } from '@coze-workflow/base/api';
import {
EntityManager,
type PluginContext,
PlaygroundConfigEntity,
} from '@flowgram-adapter/free-layout-editor';
import { checkEncapsulateGray } from './utils';
import {
type NodeMeta,
type GetGlobalStateOption,
type GetNodeTemplateOption,
} from './types';
@injectable()
export class EncapsulateContext {
@inject(EntityManager)
protected readonly entityManager: EntityManager;
@inject(PlaygroundConfigEntity)
private playgroundConfigEntity: PlaygroundConfigEntity;
private pluginContext: PluginContext;
private getGlobalStateOption: GetGlobalStateOption = () => ({
spaceId: '',
flowMode: WorkflowMode.Workflow,
info: {
name: '',
},
});
private getNodeMetaTemplateOption: GetNodeTemplateOption = () => () =>
undefined;
setGetGlobalState(getGlobalState: GetGlobalStateOption) {
this.getGlobalStateOption = getGlobalState;
}
setGetNodeTemplate(getNodeTemplate: GetNodeTemplateOption) {
this.getNodeMetaTemplateOption = getNodeTemplate;
}
getNodeTemplate(type: StandardNodeType): NodeMeta | undefined {
return this.getNodeMetaTemplateOption(this.pluginContext)(type);
}
setPluginContext(context: PluginContext) {
this.pluginContext = context;
}
private get globalState() {
return this.getGlobalStateOption(this.pluginContext);
}
get spaceId() {
return this.globalState?.spaceId;
}
get flowName() {
return this.globalState?.info?.name;
}
get flowMode() {
return this.globalState?.flowMode;
}
get isChatFlow() {
return this.globalState?.flowMode === WorkflowMode.ChatFlow;
}
get enabled() {
return checkEncapsulateGray() && !this.playgroundConfigEntity.readonly;
}
get projectId() {
return this.globalState?.projectId;
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const ENCAPSULATE_START_END_PAD = 60;

View File

@@ -0,0 +1,429 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { groupBy, uniq } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { type WorkflowJSON } from '@coze-workflow/base';
import {
type WorkflowLineEntity,
WorkflowNodeLinesData,
type WorkflowPortEntity,
type WorkflowNodeEntity,
WorkflowLinesManager,
type WorkflowEdgeJSON,
type WorkflowLinePortInfo,
} from '@flowgram-adapter/free-layout-editor';
import { isNodesInSubCanvas } from '../utils/subcanvas';
import { type ConnectPortsInfo } from './types';
@injectable()
export class EncapsulateLinesService {
@inject(WorkflowLinesManager) declare linesManager: WorkflowLinesManager;
/**
* 获取有效的封装连接端口
* @param nodes 封装范围内的节点数组
* @throws Error 输入线不符合封装规则
* @throws Error 输出线不符合封装规则
* @returns 封装范围内的输入和输出端口对象
*/
getValidEncapsulateConnectPorts(
nodes: WorkflowNodeEntity[],
): ConnectPortsInfo {
const inputLines = this.getEncapsulateNodesInputLines(nodes);
const outputLines = this.getEncapsulateNodesOutputLines(nodes);
if (!this.validateEncapsulateLines(inputLines)) {
throw new Error('输入线不符合封装规则');
}
if (!this.validateEncapsulateLines(outputLines)) {
throw new Error('输出线不符合封装规则');
}
return {
inputLines,
outputLines,
fromPorts: uniq(inputLines.map(_line => _line.fromPort)),
toPorts: uniq(
outputLines
.map(_line => _line.toPort)
.filter(Boolean) as WorkflowPortEntity[],
),
};
}
/**
* Connects the specified ports to the given node.
*
* @param portInfo - Information about the ports to be connected.
* @param node - The node to which the ports will be connected.
*
* This method iterates over the `fromPorts` and `toPorts` arrays, creating lines
* between each port and the specified node using the `linesManager.createLine` method.
*/
connectPortsToNode(portInfo: ConnectPortsInfo, node: WorkflowNodeEntity) {
const { fromPorts, toPorts } = portInfo;
fromPorts.forEach(_fromPort => {
this.linesManager.createLine({
from: _fromPort.node.id,
fromPort: _fromPort.portID,
to: node.id,
});
});
toPorts.forEach(_toPort => {
this.linesManager.createLine({
from: node.id,
to: _toPort.node.id,
toPort: _toPort.portID,
});
});
}
/**
* 获取封装范围内的所有输入线
* @param nodes 流程图节点数组
* @returns 封装范围内的输入线数组
*/
getEncapsulateNodesInputLines(
nodes: WorkflowNodeEntity[],
): WorkflowLineEntity[] {
return uniq(
nodes
.map(_node => _node.getData(WorkflowNodeLinesData).inputLines)
.flat(),
).filter(
_line =>
_line.from &&
!nodes.includes(_line.from) &&
(isNodesInSubCanvas(nodes) || !this.isSubCanvasLinkLine(_line)),
);
}
/**
* 获取封装范围内的所有输出线
* @param nodes 流程图节点数组
* @returns 封装范围内的输出线数组
*/
getEncapsulateNodesOutputLines(
nodes: WorkflowNodeEntity[],
): WorkflowLineEntity[] {
return uniq(
nodes
.map(_node => _node.getData(WorkflowNodeLinesData).outputLines)
.flat(),
).filter(
_line =>
_line.to &&
!nodes.includes(_line.to) &&
(isNodesInSubCanvas(nodes) || !this.isSubCanvasLinkLine(_line)),
);
}
/**
* 校验封装范围内的输入和输出线是否符合封装规则
*/
validateEncapsulateLines(lines: WorkflowLineEntity[]): boolean {
const isFromPortUniq =
uniq(lines.map(_line => _line.fromPort)).length === 1;
const isToPortUniq = uniq(lines.map(_line => _line.toPort)).length === 1;
return isFromPortUniq || isToPortUniq;
}
/**
* 创建封装连线
* @param ports
* @param subFlowNode
* @returns
*/
createEncapsulateLines(
ports: ConnectPortsInfo,
subFlowNode: WorkflowNodeEntity,
) {
const inputLines: WorkflowLineEntity[] = [];
const outputLines: WorkflowLineEntity[] = [];
ports.inputLines.forEach(line => {
const inputLine = this.linesManager.createLine({
from: line.from.id,
fromPort: line.fromPort.portID,
to: subFlowNode.id,
});
if (!inputLine) {
throw new Error('create input line failed');
}
inputLines.push(inputLine);
});
ports.outputLines.forEach(line => {
if (!line.to) {
return;
}
const outputLine = this.linesManager.createLine({
from: subFlowNode.id,
to: line.to.id,
toPort: line.toPort?.portID,
});
if (!outputLine) {
throw new Error('create output line failed');
}
outputLines.push(outputLine);
});
return {
inputLines,
outputLines,
};
}
/**
* 创建解封线
* @param options
*/
createDecapsulateLines(options: {
node: WorkflowNodeEntity;
workflowJSON: WorkflowJSON;
startNodeId: string;
endNodeId: string;
idsMap: Map<string, string>;
}) {
const { node, startNodeId, endNodeId, idsMap, workflowJSON } = options;
const edges = [
...workflowJSON.edges,
// 子画布中的连线
...workflowJSON.nodes
.map(n => n.edges)
.filter(Boolean)
.flat(),
] as WorkflowEdgeJSON[];
const edgesGroup = groupBy(edges, edge => {
if (edge.sourceNodeID === startNodeId) {
return 'input';
}
if (edge.targetNodeID === endNodeId) {
return 'output';
}
return 'internal';
});
// 内部连线
const internalLines = this.createDecapsulateInternalLines(
edgesGroup.internal || [],
idsMap,
);
// 输入连线
const inputLines = this.createDecapsulateInputLines(
edgesGroup.input || [],
node,
idsMap,
);
// 输出连线
const outputLines = this.createDecapsulateOutputLines(
edgesGroup.output || [],
node,
idsMap,
);
return {
inputLines,
outputLines,
internalLines,
};
}
/**
* 创建解封内部连线
* @param internalEdges
* @param idsMap
* @returns
*/
private createDecapsulateInternalLines(
internalEdges: WorkflowEdgeJSON[],
idsMap: Map<string, string>,
) {
const createdLines: WorkflowLineEntity[] = [];
internalEdges.forEach(edge => {
if (!idsMap.has(edge.sourceNodeID) || !idsMap.has(edge.targetNodeID)) {
return;
}
const line = {
from: idsMap.get(edge.sourceNodeID) as string,
to: idsMap.get(edge.targetNodeID) as string,
fromPort: edge.sourcePortID,
toPort: edge.targetPortID,
};
if (line.fromPort === 'loop-function-inline-output') {
line.from = `LoopFunction_${line.from}`;
}
if (line.toPort === 'loop-function-inline-input') {
line.to = `LoopFunction_${line.to}`;
}
if (line.fromPort === 'batch-function-inline-output') {
line.from = `BatchFunction_${line.from}`;
}
if (line.toPort === 'batch-function-inline-input') {
line.to = `BatchFunction_${line.to}`;
}
this.createLine(line, createdLines);
});
return createdLines;
}
/**
* 创建解封输入连线
* @param inputEdges
* @param node
* @param idsMap
* @returns
*/
private createDecapsulateInputLines(
inputEdges: WorkflowEdgeJSON[],
node: WorkflowNodeEntity,
idsMap: Map<string, string>,
) {
const createdLines: WorkflowLineEntity[] = [];
const { inputLines } = node.getData(WorkflowNodeLinesData);
// 封装多开头+封装上游多个输入 不创建连线
if (inputLines.length > 1 && inputEdges.length > 1) {
return createdLines;
}
inputLines.forEach(inputLine => {
inputEdges.forEach(edge => {
if (!idsMap.has(edge.targetNodeID)) {
return;
}
this.createLine(
{
from: inputLine.from.id,
fromPort: inputLine.fromPort.portID,
to: idsMap.get(edge.targetNodeID) as string,
toPort: edge.targetPortID,
},
createdLines,
);
});
});
return createdLines;
}
/**
* 创建解封输出连线
* @param outputEdges
* @param node
* @param idsMap
* @returns
*/
private createDecapsulateOutputLines(
outputEdges: WorkflowEdgeJSON[],
node: WorkflowNodeEntity,
idsMap: Map<string, string>,
) {
const createdLines: WorkflowLineEntity[] = [];
const { outputLines } = node.getData(WorkflowNodeLinesData);
// 封装多输出+封装下游多个输出 不创建连线
if (outputLines.length > 1 && outputEdges.length > 1) {
return createdLines;
}
outputLines.forEach(outputLine => {
outputEdges.forEach(edge => {
if (!idsMap.has(edge.sourceNodeID)) {
return;
}
this.createLine(
{
from: idsMap.get(edge.sourceNodeID) as string,
fromPort: edge.sourcePortID,
to: outputLine.to?.id,
toPort: outputLine.toPort?.portID,
},
createdLines,
);
});
});
return createdLines;
}
/**
* 创建连线
* @param info
* @param createdLines
*/
private createLine(
info: WorkflowLinePortInfo,
createdLines?: WorkflowLineEntity[],
) {
const line = this.linesManager.createLine(info);
if (line && createdLines) {
createdLines.push(line);
}
}
private isSubCanvasLinkLine(line: WorkflowLineEntity): boolean {
if (
// loop内的最后一根线
line.toPort?.portID === 'loop-function-inline-input' ||
// loop内的第一根线
line.fromPort.portID === 'loop-function-inline-output'
) {
return true;
}
if (
line.toPort?.portID === 'batch-function-inline-input' ||
line.fromPort.portID === 'batch-function-inline-output'
) {
return true;
}
if (
line.fromPort.portID === 'loop-output-to-function' &&
line.toPort?.portID === 'loop-function-input'
) {
return true;
}
if (
line.fromPort.portID === 'batch-output-to-function' &&
line.toPort?.portID === 'batch-function-input'
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { DisposableCollection } from '@flowgram-adapter/common';
import { type EncapsulateManager } from './types';
@injectable()
export class EncapsulateManagerImpl implements EncapsulateManager {
private toDispose: DisposableCollection = new DisposableCollection();
init() {
this.toDispose.pushAll([]);
}
dispose() {
this.toDispose.dispose();
}
}

View File

@@ -0,0 +1,447 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-params */
/* eslint-disable @typescript-eslint/no-magic-numbers */
import { get, set } from 'lodash-es';
import { inject, injectable } from 'inversify';
import {
type DragNodeOperationValue,
FreeOperationType,
HistoryService,
} from '@flowgram-adapter/free-layout-editor';
import {
PlaygroundContext,
TransformData,
} from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowNodeJSON,
type WorkflowNodeEntity,
WorkflowDocument,
} from '@flowgram-adapter/free-layout-editor';
import { delay, type IPoint } from '@flowgram-adapter/common';
import { WorkflowNodesService } from '@coze-workflow/nodes';
import { StandardNodeType } from '@coze-workflow/base/types';
import { getNodesParentId } from '../utils/get-nodes-parent-id';
import { getNodePoint } from '../utils';
import { EncapsulateGenerateService } from '../generate';
import { EncapsulateContext } from '../encapsulate-context';
import { ENCAPSULATE_START_END_PAD } from './constants';
/**
* 节点操作服务
*/
@injectable()
export class EncapsulateNodesService {
@inject(WorkflowDocument)
private workflowDocument: WorkflowDocument;
@inject(WorkflowNodesService)
private workflowNodesService: WorkflowNodesService;
@inject(EncapsulateContext)
private encapsulateContext: EncapsulateContext;
@inject(EncapsulateGenerateService)
private encapsulateGenerateService: EncapsulateGenerateService;
@inject(HistoryService)
private historyService: HistoryService;
@inject(PlaygroundContext)
private playgroundContext: PlaygroundContext;
/**
* 获取一批节点的中心点
* @param nodes 节点数组
* @returns 中心点的坐标对象
*/
getNodesMiddlePoint(
nodes: Array<WorkflowNodeEntity | WorkflowNodeJSON>,
): IPoint {
if (nodes.length === 0) {
throw new Error('选中节点不能为空');
}
const rect = this.getNodesRect(nodes);
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
};
}
/**
* 创建解封节点
* @param sourceNode 原始被解封的节点
* @param nodes 要生成节点的node json数据
* @returns
*/
async createDecapsulateNodes(
sourceNode: WorkflowNodeEntity,
nodes: WorkflowNodeJSON[],
parentId?: string,
) {
const { startNode, endNode, middleNodes } = this.groupNodes(nodes);
if (!startNode || !endNode) {
throw new Error('start or end node not found');
}
const nodePoint = getNodePoint(sourceNode);
const centerPoint = this.getNodesMiddlePoint(middleNodes);
// 平移到中心
const translate: IPoint = {
x: nodePoint.x - centerPoint.x,
y: nodePoint.y - centerPoint.y,
};
const idsMap = new Map<string, string>();
for (const nodeJSON of middleNodes) {
await this.createDecapsulateNode(nodeJSON, translate, idsMap, parentId);
}
return {
idsMap,
startNode,
endNode,
middleNodes,
};
}
/**
* 创建封装节点
* @param name
* @param nodes
* @returns
*/
async createEncapsulateNode(
workflowId: string,
name: string,
nodes: WorkflowNodeEntity[],
) {
const pos = this.getNodesMiddlePoint(nodes);
const type = StandardNodeType.SubWorkflow;
const nodeJSON = this.encapsulateGenerateService.generateSubWorkflowNode({
spaceId: this.encapsulateContext.spaceId,
workflowId,
name,
desc: name,
});
await this.beforeCreate(type, nodeJSON);
const subFlowNode = await this.workflowDocument.createWorkflowNodeByType(
type,
pos,
nodeJSON,
getNodesParentId(nodes),
);
return subFlowNode;
}
/**
* 通过json创建一个新节点
* @param json
* @returns
*/
async createDecapsulateNode(
nodeJSON: WorkflowNodeJSON,
translate: IPoint,
idsMap?: Map<string, string>,
parentId?: string,
) {
const json = this.getDecapsulateNodeJSON(nodeJSON, translate, idsMap);
await this.beforeCreate(json.type as StandardNodeType, json);
const newNode = await this.workflowDocument.createWorkflowNode(
json,
true,
parentId || 'root',
);
return newNode;
}
/**
* 解封布局,将所有节点向外平移一半的宽高
*/
decapsulateLayout(sourceNode: WorkflowNodeEntity, nodes: WorkflowNodeJSON[]) {
const point = getNodePoint(sourceNode);
const { width, height } = this.getNodesRect(
nodes.filter(
n =>
![StandardNodeType.Start, StandardNodeType.End].includes(
n.type as StandardNodeType,
),
),
);
const value: DragNodeOperationValue = {
ids: [],
value: [],
oldValue: [],
};
this.workflowDocument.getAllNodes().reduce((previousValue, node) => {
const nodePoint = getNodePoint(node);
if (nodePoint.x > point.x) {
nodePoint.x += width / 2;
}
if (nodePoint.y > point.y) {
nodePoint.y += height / 2;
}
if (nodePoint.x < point.x) {
nodePoint.x -= width / 2;
}
if (nodePoint.y < point.y) {
nodePoint.y -= height / 2;
}
const transformData = node.getData<TransformData>(TransformData);
previousValue.ids.push(node.id);
previousValue.value.push(nodePoint);
previousValue.oldValue.push({
x: transformData.position.x,
y: transformData.position.y,
});
return previousValue;
}, value);
this.historyService.pushOperation({
type: FreeOperationType.dragNodes,
value,
});
}
/**
* 获取解封后的节点JSON
* @param nodeJSON 原来的node json
* @param translate 平移的距离
* @param idsMap ids映射
* @returns
*/
private getDecapsulateNodeJSON(
nodeJSON: WorkflowNodeJSON,
translate: IPoint,
idsMap?: Map<string, string>,
): WorkflowNodeJSON {
const id = this.workflowNodesService.createUniqID();
if (idsMap) {
idsMap.set(nodeJSON.id, id);
}
nodeJSON.id = id;
const title = get(nodeJSON, 'data.nodeMeta.title') || nodeJSON.type;
const uniqueTitle = this.workflowNodesService.createUniqTitle(title);
set(nodeJSON, 'data.nodeMeta.title', uniqueTitle);
if (!nodeJSON.meta) {
nodeJSON.meta = {};
}
nodeJSON.meta.position = this.getDecapsulatePosition(nodeJSON, translate);
if (nodeJSON.blocks) {
nodeJSON.blocks = nodeJSON.blocks.map(block =>
this.getDecapsulateNodeJSON(block, translate, idsMap),
);
}
return nodeJSON;
}
/**
* 获取解封后的节点坐标
* @param nodeJSON
* @param translate
* @returns
*/
private getDecapsulatePosition(
nodeJSON: WorkflowNodeJSON,
translate: IPoint,
) {
const position = nodeJSON.meta?.position || {
x: 0,
y: 0,
};
return {
x: position.x + translate.x,
y: position.y + translate.y,
};
}
/**
* 节点分组
* @param nodes
* @returns
*/
private groupNodes(nodes: WorkflowNodeJSON[]) {
const middleNodes: WorkflowNodeJSON[] = [];
let startNode: WorkflowNodeJSON | undefined;
let endNode: WorkflowNodeJSON | undefined;
nodes.forEach(node => {
if (node.type === StandardNodeType.Start) {
startNode = node;
} else if (node.type === StandardNodeType.End) {
endNode = node;
} else {
middleNodes.push(node);
}
});
return {
startNode,
endNode,
middleNodes,
};
}
/**
* 获取多个节点合起来的宽高
* @param nodes
* @returns
*/
private getNodesRect(nodes: Array<WorkflowNodeEntity | WorkflowNodeJSON>) {
const x1 = Math.min(...nodes.map(node => getNodePoint(node).x));
const x2 = Math.max(...nodes.map(node => getNodePoint(node).x));
const y1 = Math.min(...nodes.map(node => getNodePoint(node).y));
const y2 = Math.max(...nodes.map(node => getNodePoint(node).y));
const width = x2 - x1;
const height = y2 - y1;
return {
width,
height,
x: x1,
y: y1,
};
}
getEncapsulateStartEndRects(nodes: WorkflowNodeEntity[]) {
const boundaryNodes = this.getBoundaryNodes(nodes);
if (!boundaryNodes.left || !boundaryNodes.right) {
throw new Error('boundaryNodes left or right node not found');
}
return {
start: this.getEncapsulateStartEndRect(
StandardNodeType.Start,
boundaryNodes.left,
),
end: this.getEncapsulateStartEndRect(
StandardNodeType.End,
boundaryNodes.right,
),
};
}
/**
* 删除节点
* @param node */
async deleteNodes(nodes: WorkflowNodeEntity[]) {
nodes.forEach(node => {
node.dispose();
});
// 有些节点删除会有删除出相关连线,等待其执行完成
await delay(10);
}
private async beforeCreate(
nodeType: string,
nodeJSON: Partial<WorkflowNodeJSON>,
) {
if (!nodeType) {
return;
}
const nodeRegistry = this.workflowDocument.getNodeRegister(nodeType);
await nodeRegistry?.onInit?.(
nodeJSON as WorkflowNodeJSON,
this.playgroundContext,
);
}
private getEncapsulateStartEndRect(
type: StandardNodeType.Start | StandardNodeType.End,
boundaryNode: WorkflowNodeEntity,
) {
const node = this.workflowDocument
.getAllNodes()
.find(item => item.flowNodeType === type);
if (!node) {
throw new Error('getEncapsulateStartEndPoint node not found');
}
const nodeReact = this.getNodeRect(node);
const boundaryNodeReact = this.getNodeRect(boundaryNode);
const translateX =
type === StandardNodeType.Start
? -ENCAPSULATE_START_END_PAD - nodeReact.width
: ENCAPSULATE_START_END_PAD + boundaryNodeReact.width;
const translateY = (boundaryNodeReact.height - nodeReact.height) / 2;
return {
x: boundaryNodeReact.x + translateX,
y: boundaryNodeReact.y + translateY,
width: nodeReact.width,
height: nodeReact.height,
};
}
private getNodeRect(node: WorkflowNodeEntity) {
const transformData = node.getData<TransformData>(TransformData);
return {
x: transformData.bounds.left,
y: transformData.bounds.top,
width: transformData.bounds.width,
height: transformData.bounds.height,
};
}
private getBoundaryNodes(nodes: WorkflowNodeEntity[]) {
const boundaryNodes: {
left?: WorkflowNodeEntity;
right?: WorkflowNodeEntity;
} = {};
nodes.reduce((previousValue, node) => {
if (!previousValue.left) {
previousValue.left = node;
}
if (!previousValue.right) {
previousValue.right = node;
}
if (getNodePoint(node).x < getNodePoint(previousValue.left).x) {
previousValue.left = node;
}
if (getNodePoint(node).x > getNodePoint(previousValue.right).x) {
previousValue.right = node;
}
return previousValue;
}, boundaryNodes);
return boundaryNodes;
}
}

View File

@@ -0,0 +1,327 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable, inject } from 'inversify';
import { WorkflowMode } from '@coze-workflow/base/api';
import {
reporter,
StandardNodeType,
type WorkflowJSON,
} from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { HistoryService } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { delay, Emitter, logger } from '@flowgram-adapter/common';
import { EncapsulateValidateService } from '../validate';
import { isNodeInSubCanvas } from '../utils/subcanvas';
import { randomNameSuffix } from '../utils/random-name-suffix';
import { hasSubCanvasNodes } from '../utils/has-sub-canvas-nodes';
import { getSubWorkflowInfo } from '../utils';
import { EncapsulateGenerateService } from '../generate';
import { EncapsulateContext } from '../encapsulate-context';
import { EncapsulateApiService } from '../api';
import {
type EncapsulateErrorResult,
type EncapsulateResult,
type EncapsulateService,
} from './types';
import { EncapsulateVariableService } from './encapsulate-variable-service';
import { EncapsulateNodesService } from './encapsulate-nodes-service';
import { EncapsulateLinesService } from './encapsulate-lines-service';
@injectable()
export class EncapsulateServiceImpl implements EncapsulateService {
private onEncapsulateEmitter = new Emitter<EncapsulateResult>();
public readonly onEncapsulate = this.onEncapsulateEmitter.event;
@inject(EncapsulateValidateService)
private encapsulateValidateService: EncapsulateValidateService;
@inject(EncapsulateGenerateService)
private encapsulateGenerateService: EncapsulateGenerateService;
@inject(WorkflowSelectService)
private workflowSelectService: WorkflowSelectService;
@inject(EncapsulateNodesService)
private encapsulateNodesService: EncapsulateNodesService;
@inject(EncapsulateApiService)
private encapsulateApiService: EncapsulateApiService;
@inject(EncapsulateContext)
private encapsulateContext: EncapsulateContext;
@inject(EncapsulateLinesService)
private encapsulateLinesService: EncapsulateLinesService;
@inject(EncapsulateVariableService)
private encapsulateVariableService: EncapsulateVariableService;
@inject(HistoryService)
private historyService: HistoryService;
@inject(WorkflowDocument)
private workflowDocument: WorkflowDocument;
private encapsulating = false;
private decapsulating = false;
validate() {
const { selectedNodes } = this.workflowSelectService;
return this.encapsulateValidateService.validate(selectedNodes);
}
canEncapsulate(): boolean {
return this.encapsulateContext.enabled;
}
async encapsulate() {
if (!this.canEncapsulate()) {
return this.encapsulateError('encapsulate is not enabled');
}
if (this.encapsulating) {
return this.encapsulateError('encapsulating');
}
const name = `${this.encapsulateContext.flowName || ''}_sub_${randomNameSuffix()}`;
const { selectedNodes } = this.workflowSelectService;
if (selectedNodes.length < 2) {
return this.encapsulateError('at least 2 nodes');
}
reporter.event({ eventName: 'workflow_encapsulate' });
this.encapsulating = true;
let res;
try {
res = await this.encapsulateNodes(name, selectedNodes);
// 封装完成选中封装后的节点
if (res.success) {
this.workflowSelectService.selectNode(res.subFlowNode);
}
this.onEncapsulateEmitter.fire(res);
} finally {
this.encapsulating = false;
}
return res;
}
canDecapsulate(node: WorkflowNodeEntity) {
return (
this.encapsulateContext.enabled &&
node.flowNodeType === StandardNodeType.SubWorkflow
);
}
async decapsulate(node: WorkflowNodeEntity) {
if (!node || !this.canDecapsulate(node) || this.decapsulating) {
return;
}
reporter.event({ eventName: 'workflow_decapsulate' });
this.decapsulating = true;
try {
await this.decapsulateNode(node);
} finally {
this.decapsulating = false;
}
}
async decapsulateNode(node: WorkflowNodeEntity) {
const info = getSubWorkflowInfo(node);
if (!info) {
return;
}
// 获取流程数据
const workflow = await this.encapsulateApiService.getWorkflow(
info.spaceId && info.spaceId !== '0'
? info.spaceId
: this.encapsulateContext.spaceId,
info.workflowId,
info.workflowVersion,
);
if (!workflow) {
return;
}
if (workflow.nodes.length <= 2) {
return;
}
// 如果解封子画布里面的节点,需要校验是否有子画布
if (
isNodeInSubCanvas(node) &&
hasSubCanvasNodes(this.workflowDocument, workflow.nodes)
) {
Toast.warning(
I18n.t(
'workflow_encapsulate_toast_batch_or_loop',
undefined,
'在循环/批处理中解散的工作流不能包含循环或批处理节点',
),
);
return;
}
this.historyService.startTransaction();
// 以节点为中心向外扩张
this.encapsulateNodesService.decapsulateLayout(node, workflow.nodes);
// 创建节点
const { idsMap, startNode, endNode, middleNodes } =
await this.encapsulateNodesService.createDecapsulateNodes(
node,
workflow.nodes,
node.parent?.id,
);
// 解封后更新变量引用
this.encapsulateVariableService.updateVarsAfterDecapsulate(node, {
idsMap,
startNode,
endNode,
middleNodes,
});
// 创建连线
this.encapsulateLinesService.createDecapsulateLines({
node,
workflowJSON: workflow as WorkflowJSON,
startNodeId: startNode.id,
endNodeId: endNode.id,
idsMap,
});
await delay(30);
// 移除老节点
await this.encapsulateNodesService.deleteNodes([node]);
this.historyService.endTransaction();
}
dispose() {
this.onEncapsulateEmitter.dispose();
}
private async encapsulateNodes(
name: string,
nodes: WorkflowNodeEntity[],
): Promise<EncapsulateResult> {
const ports =
this.encapsulateLinesService.getValidEncapsulateConnectPorts(nodes);
const encapsulateVars =
this.encapsulateVariableService.getEncapsulateVars(nodes);
const startEndRects =
this.encapsulateNodesService.getEncapsulateStartEndRects(nodes);
// 生成 json并更新 JSON 内的变量引用
const json = await this.encapsulateGenerateService.generateWorkflowJSON(
nodes,
{
startEndRects,
},
);
let workflowId;
let flowMode = WorkflowMode.Workflow;
if (
this.encapsulateContext.isChatFlow &&
encapsulateVars.startVars.USER_INPUT &&
encapsulateVars.startVars.CONVERSATION_NAME
) {
flowMode = WorkflowMode.ChatFlow;
}
try {
// 保存流程
const res = await this.encapsulateApiService.encapsulateWorkflow({
name,
desc: name,
json,
flowMode,
});
workflowId = res?.workflowId;
} catch (error) {
logger.error(error);
return this.encapsulateError('encapsulate workflow failed');
}
if (!workflowId) {
return this.encapsulateError(
'encapsulate workflow failed no workflowId returned',
);
}
this.historyService.startTransaction();
// 替换成调用流程节点
const subFlowNode =
await this.encapsulateNodesService.createEncapsulateNode(
workflowId,
name,
nodes,
);
// 更新变量引用(上下游 + 封装节点本身输入)
this.encapsulateVariableService.updateVarsAfterEncapsulate(
subFlowNode,
nodes,
encapsulateVars,
);
// 移除老节点
await this.encapsulateNodesService.deleteNodes(nodes);
// 生成新连线
const { inputLines, outputLines } =
this.encapsulateLinesService.createEncapsulateLines(ports, subFlowNode);
await delay(30);
this.historyService.endTransaction();
return {
success: true,
subFlowNode,
inputLines,
outputLines,
workflowId,
projectId: this.encapsulateContext.projectId,
};
}
private encapsulateError(message: string): EncapsulateErrorResult {
return {
success: false,
message,
};
}
}

View File

@@ -0,0 +1,488 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-len */
import { get, set, uniq } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { produce } from 'immer';
import {
type FormModelV2,
isFormV2,
} from '@flowgram-adapter/free-layout-editor';
import { FlowNodeFormData } from '@flowgram-adapter/free-layout-editor';
import {
type FlowNodeJSON,
type FlowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowJSON,
} from '@flowgram-adapter/free-layout-editor';
import {
type UpdateRefInfo,
WorkflowNodeRefVariablesData,
WorkflowVariableService,
type WorkflowVariable,
variableUtils,
isGlobalVariableKey,
} from '@coze-workflow/variable';
import {
type InputValueDTO,
type InputValueVO,
StandardNodeType,
type ValueExpression,
ValueExpressionType,
} from '@coze-workflow/base';
import {
traverseRefsInNodeJSON,
sortVariables,
variableOrder,
} from '../utils/variable';
import { getNodesWithSubCanvas } from '../utils/get-nodes-with-sub-canvas';
import { EncapsulateContext } from '../encapsulate-context';
import {
type VariableMap,
type EncapsulateVars,
type DecapsulateContext,
} from './types';
@injectable()
export class EncapsulateVariableService {
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowVariableService) variableService: WorkflowVariableService;
@inject(EncapsulateContext) encapsulateContext: EncapsulateContext;
/**
* 获取封装变量
* @param nodes
* @returns
*/
getEncapsulateVars(_nodes: FlowNodeEntity[]): EncapsulateVars {
const nodes = getNodesWithSubCanvas(_nodes);
return {
startVars: this.getEncapsulateStartVars(nodes),
endVars: this.getEncapsulateEndVars(nodes),
};
}
/**
* 主动封装时,更新 WorkflowJSON 内部的变量引用
* @param workflowJSON
* @param vars
* @returns
*/
updateVarsInEncapsulateJSON(
workflowJSON: WorkflowJSON,
vars: EncapsulateVars,
) {
const startId =
workflowJSON.nodes.find(_node => _node.type === StandardNodeType.Start)
?.id || '100001';
const nextWorkflowJSON = produce(workflowJSON, draft => {
this.updateVarsInEncapsulateNodesJSON(
draft.nodes as WorkflowJSON['nodes'],
vars,
startId,
);
});
return nextWorkflowJSON;
}
/**
* 更新节点JSON中的变量
* @param nodes
* @param vars
* @param startId
*/
updateVarsInEncapsulateNodesJSON(
nodes: WorkflowJSON['nodes'],
vars: EncapsulateVars,
startId: string,
) {
const { startVars, endVars } = vars;
nodes.forEach(_node => {
if (!_node.data) {
_node.data = {};
}
// 开始节点设置
if (_node.type === StandardNodeType.Start) {
_node.data.outputs = Object.entries(startVars).map(_entry => {
const [name, variable] = _entry;
return {
...variable.dtoMeta,
name,
};
});
return;
}
// 结束节点设置
if (_node.type === StandardNodeType.End) {
_node.data.inputs = {
terminatePlan: 'returnVariables',
inputParameters: Object.entries(endVars).map(_entry => {
const [name, variable] = _entry;
return {
name,
input: variable.refExpressionDTO,
};
}),
};
return;
}
traverseRefsInNodeJSON(_node.data, _ref => {
if (!_ref.content) {
return;
}
const targetKeyPath = [
_ref.content?.blockID,
...(_ref.content?.name?.split('.') || []),
];
const targetEntry = Object.entries(startVars).find(
([, _var]) =>
_var.keyPath[0] === targetKeyPath[0] &&
_var.keyPath[1] === targetKeyPath[1],
);
// 如果命中开始节点的变量,则替换为开始节点的变量
if (targetEntry) {
_ref.content.blockID = startId;
_ref.content.name = [targetEntry[0], ...targetKeyPath.slice(2)].join(
'.',
);
}
});
// 子画布场景需要递归
if (_node.blocks) {
this.updateVarsInEncapsulateNodesJSON(_node.blocks, vars, startId);
}
});
}
/**
* 封装后更新上下游的变量引用
* @param subFlowNode 封装节点
* @param selectNodes 选中的节点
* @param vars 封装变量
*/
updateVarsAfterEncapsulate(
subFlowNode: FlowNodeEntity,
selectNodes: FlowNodeEntity[],
vars: EncapsulateVars,
) {
const { startVars, endVars } = vars;
// 封装节点输入变量
this.setSubFlowNodeInputs(subFlowNode, startVars);
// 下游引用封装节点的变量
const updateRefInfos: UpdateRefInfo[] = Object.entries(endVars).map(
_entry => {
const [name, variable] = _entry;
return {
beforeKeyPath: variable.keyPath,
afterKeyPath: [subFlowNode.id, name, ...variable.keyPath.slice(2)],
};
},
);
this.getBeyondNodes([...selectNodes, subFlowNode]).forEach(_node => {
_node
.getData(WorkflowNodeRefVariablesData)
.batchUpdateRefs(updateRefInfos);
});
}
/**
* 解封装后更新上下游的变量引用
*/
updateVarsAfterDecapsulate(
sourceNode: FlowNodeEntity,
group: DecapsulateContext,
) {
const { startNode, endNode, middleNodes, idsMap } = group;
const decapsulateNodes = this.flatNodeJSONs(middleNodes)
.map(_node => this.document.getNode(_node.id || ''))
.filter(Boolean) as FlowNodeEntity[];
// 1, 更新封装节点内的变量
const sourceInputParameters: Record<string, ValueExpression> = sourceNode
.getData(FlowNodeFormData)
.formModel.getFormItemValueByPath('/inputs/inputParameters');
const updateInsideRefInfos: UpdateRefInfo[] = [
// 变量引用节点 Id 更新
...idsMap.entries().map(_entry => ({
beforeKeyPath: [_entry[0]],
afterKeyPath: [_entry[1]],
})),
// 输入到封装节点内的变量
...Object.entries(sourceInputParameters || {}).map(_entry => {
const [name, value] = _entry;
return {
beforeKeyPath: [startNode?.id || '100001', name],
afterKeyPath: get(value, 'content.keyPath'),
afterExpression: value,
};
}),
];
decapsulateNodes.forEach(_node => {
_node
.getData(WorkflowNodeRefVariablesData)
.batchUpdateRefs(updateInsideRefInfos);
});
// 2. 下游引用封装节点内的变量
const endOutputs: InputValueDTO[] =
get(endNode?.data || {}, 'inputs.inputParameters') || [];
// 从封装节点内输出的变量
const updateOutsideRefInfos: UpdateRefInfo[] = endOutputs.map(_output => {
const { name, input } = _output || {};
const beforeKeyPath = [sourceNode.id, name || ''];
const expression = variableUtils.valueExpressionToVO(
input,
this.variableService,
);
if (expression.type === ValueExpressionType.REF) {
const [nodeId, ...paths] = get(expression, 'content.keyPath') || [];
const actualNodeId = idsMap.get(nodeId) || nodeId;
return {
beforeKeyPath,
afterKeyPath: [actualNodeId, ...paths],
};
}
return {
beforeKeyPath,
afterExpression: expression,
};
});
this.getBeyondNodes([sourceNode, ...decapsulateNodes]).forEach(_node => {
_node
.getData(WorkflowNodeRefVariablesData)
.batchUpdateRefs(updateOutsideRefInfos);
});
}
protected setSubFlowNodeInputs(
subFlowNode: FlowNodeEntity,
startVars: EncapsulateVars['startVars'],
) {
const formData = subFlowNode.getData(FlowNodeFormData);
// SubFlow 的 inputParameters 是 Object 类型
const inputParameters: Record<string, InputValueVO> = Object.entries(
startVars,
)
.sort((a, b) => variableOrder(b[0]) - variableOrder(a[0]))
.reduce((acm, _entry) => {
const [name, variable] = _entry;
return {
...acm,
[name]: {
type: ValueExpressionType.REF,
content: {
keyPath: variable.keyPath,
},
},
};
}, {});
// 新表单引擎更新数据
if (isFormV2(subFlowNode)) {
(formData.formModel as FormModelV2).setValueIn(
'inputs.inputParameters',
inputParameters,
);
} else {
// 老表单引擎更新数据
const fullData = formData.formModel.getFormItemValueByPath('/');
set(fullData, 'inputs.inputParameters', inputParameters);
formData.fireChange();
}
}
/**
* 获取封装节点的开始变量
* @param nodes
*/
protected getEncapsulateStartVars(nodes: FlowNodeEntity[]): VariableMap {
const variablesMap = this.generateVariableMap(
this.getEncapsulateNodesVars(nodes),
);
return variablesMap;
}
/**
* 获取封装节点变量
* @param _nodes
* @returns
*/
protected getEncapsulateNodesVars(
nodes: FlowNodeEntity[],
): WorkflowVariable[] {
const selectNodeIds = nodes.map(_node => _node.id);
const variables = uniq(
nodes
.map(
_node =>
Object.values(_node.getData(WorkflowNodeRefVariablesData).refs)
.filter(
_keyPath =>
!selectNodeIds.includes(_keyPath[0]) &&
!isGlobalVariableKey(_keyPath[0]),
)
.map(_keyPath =>
this.variableService.getWorkflowVariableByKeyPath(
// 产品要求,只要取第一级即可
_keyPath.slice(0, 2),
{ node: _node },
),
)
.filter(Boolean) as WorkflowVariable[],
)
.flat(),
);
return sortVariables(variables);
}
/**
* 获取封装节点的结束变量
* @param nodes
*/
protected getEncapsulateEndVars(nodes: FlowNodeEntity[]): VariableMap {
const selectNodeIds = nodes.map(_node => _node.id);
// 获取在圈选范围外的节点的所有引用圈选范围内的变量引用
const variables = uniq(
this.getBeyondNodes(nodes)
.map(
_node =>
Object.values(_node.getData(WorkflowNodeRefVariablesData).refs)
.filter(_keyPath => selectNodeIds.includes(_keyPath[0]))
.map(_keyPath =>
this.variableService.getWorkflowVariableByKeyPath(
// 产品要求,只要取第一级即可
_keyPath.slice(0, 2),
{ node: _node },
),
)
.filter(Boolean) as WorkflowVariable[],
)
.flat(),
);
return this.generateVariableMap(variables);
}
/**
* 获取圈选范围外的节点
* @param nodes
* @returns
*/
protected getBeyondNodes(nodes: FlowNodeEntity[]) {
const selectNodeIds = nodes.map(_node => _node.id);
return this.document
.getAllNodes()
.filter(_node => !selectNodeIds.includes(_node.id));
}
/**
* 生成变量映射
* @param variables
*/
protected generateVariableMap(variables: WorkflowVariable[]): VariableMap {
const variablesMap = variables.reduce((acm, _variable) => {
const _keyPath = _variable.keyPath || [];
let name = _keyPath[1];
let index = 1;
// 如果 name 已经存在,或者和系统默认字段冲突,则需要添加后缀
while (acm[name] || ['BOT_USER_INPUT'].includes(name)) {
name = `${_keyPath[1]}_${index}`;
index++;
}
return {
...acm,
[name]: _variable,
};
}, {});
if (this.encapsulateContext.isChatFlow) {
return this.generateChatVariableMap(variablesMap);
}
return variablesMap;
}
/**
* 1. 情况1框选范围内所有节点的入参不包含USER_INPUT和CONVERSATION_NAME此时封装出的子流程为workflow子流程start不带默认参数
* 2. 情况2框选范围内所有节点的入参同时包含了USER_INPUT和CONVERSATION_NAME此时封装出的子流程为chatflow子流程start默认带参数USER_INPUT和CONVERSATION_NAME命名不变建立和父流程引用关系
* 3. 情况3框选范围内所有节点的入参包含了USER_INPUT和CONVERSATION_NAME中的一种此时封装出的子流程为workflow子流程start不带默认参数新建参数USER_INPUT_1或CONVERSATION_NAME_1建立和父流程引用关系
*/
protected generateChatVariableMap(variablesMap: VariableMap): VariableMap {
if (variablesMap.USER_INPUT && !variablesMap.CONVERSATION_NAME) {
variablesMap.USER_INPUT_1 = variablesMap.USER_INPUT;
delete variablesMap.USER_INPUT;
}
if (variablesMap.CONVERSATION_NAME && !variablesMap.USER_INPUT) {
variablesMap.CONVERSATION_NAME_1 = variablesMap.CONVERSATION_NAME;
delete variablesMap.CONVERSATION_NAME;
}
return variablesMap;
}
protected flatNodeJSONs(nodes: FlowNodeJSON[]) {
const result: FlowNodeJSON[] = [];
const queue = [...nodes];
while (queue.length > 0) {
const node = queue.shift();
if (node) {
result.push(node);
}
if (node?.blocks) {
queue.push(...(node.blocks as FlowNodeJSON[]));
}
}
return result;
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './encapsulate-service';
export * from './types';
export * from './encapsulate-manager';
export * from './encapsulate-lines-service';
export * from './encapsulate-nodes-service';
export * from './encapsulate-variable-service';
export * from './constants';

View File

@@ -0,0 +1,154 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type WorkflowPortEntity,
type WorkflowNodeEntity,
type WorkflowLineEntity,
type WorkflowNodeJSON,
} from '@flowgram-adapter/free-layout-editor';
import { type Event } from '@flowgram-adapter/common';
import { type WorkflowVariable } from '@coze-workflow/variable';
import { type EncapsulateValidateResult } from '../validate';
/**
* 封装结果
*/
export type EncapsulateResult =
| EncapsulateErrorResult
| EncapsulateSuccessResult;
/**
* 错误结果
*/
export interface EncapsulateErrorResult {
/**
* 状态
*/
success: false;
/**
* 消息提示
*/
message: string;
}
/**
* 成功结果
*/
export interface EncapsulateSuccessResult {
/**
* 状态
*/
success: true;
/**
* 生成的节点
*/
subFlowNode: WorkflowNodeEntity;
/**
* 生成的输入线
*/
inputLines: WorkflowLineEntity[];
/**
* 生成的输出线
*/
outputLines: WorkflowLineEntity[];
/**
* 生成的workflow id
*/
workflowId: string;
/**
* projectId
*/
projectId?: string;
}
/**
* 封装/解封服务
*/
export interface EncapsulateService {
/**
* 是否可以封装
*/
canEncapsulate: () => boolean;
/**
* 封装
*/
encapsulate: () => Promise<EncapsulateResult>;
/**
* 是否支持解封
*/
canDecapsulate: (node: WorkflowNodeEntity) => boolean;
/**
* 解封
*/
decapsulate: (node: WorkflowNodeEntity) => Promise<void>;
/**
* 校验
*/
validate: () => Promise<EncapsulateValidateResult>;
/**
* 封装成功回调
*/
onEncapsulate: Event<EncapsulateResult>;
/**
* 销毁
*/
dispose: () => void;
}
export const EncapsulateService = Symbol('EncapsulateService');
/**
* 封装/解封管理
*/
export interface EncapsulateManager {
/**
* 初始化
*/
init: () => void;
/**
* 销毁
* @returns
*/
dispose: () => void;
}
export const EncapsulateManager = Symbol('EncapsulateManager');
/**
* 连接端口
*/
export interface ConnectPortsInfo {
inputLines: WorkflowLineEntity[];
outputLines: WorkflowLineEntity[];
fromPorts: WorkflowPortEntity[];
toPorts: WorkflowPortEntity[];
}
export interface DecapsulateContext {
idsMap: Map<string, string>;
startNode?: WorkflowNodeJSON;
endNode?: WorkflowNodeJSON;
middleNodes: WorkflowNodeJSON[];
}
export type VariableMap = Record<string, WorkflowVariable>;
export interface EncapsulateVars {
startVars: VariableMap;
endVars: VariableMap;
}

View File

@@ -0,0 +1,301 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { uniqBy } from 'lodash-es';
import { injectable, inject } from 'inversify';
import { StandardNodeType } from '@coze-workflow/base/types';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowNodeEntity,
WorkflowDocument,
type WorkflowJSON,
type WorkflowEdgeJSON,
type WorkflowNodeJSON,
type WorkflowNodeRegistry,
} from '@flowgram-adapter/free-layout-editor';
import { getNodesParentId } from '../utils/get-nodes-parent-id';
import { setNodePositionByRect } from '../utils';
import { EncapsulateContext } from '../encapsulate-context';
import { EncapsulateLinesService } from '../encapsulate/encapsulate-lines-service';
import {
EncapsulateVariableService,
type ConnectPortsInfo,
} from '../encapsulate';
import {
type EncapsulateGenerateService,
type GenerateSubWorkflowNodeOptions,
} from './types';
/**
* 封装生成服务产生workflow相关的json数据
*/
@injectable()
export class EncapsulateGenerateServiceImpl
implements EncapsulateGenerateService
{
generate: (nodes: FlowNodeEntity[]) => Promise<WorkflowJSON>;
@inject(EncapsulateLinesService)
private encapsulateLinesService: EncapsulateLinesService;
@inject(EncapsulateVariableService)
private encapsulateVariableService: EncapsulateVariableService;
@inject(WorkflowDocument)
private workflowDocument: WorkflowDocument;
@inject(EncapsulateContext)
private encapsulateContext: EncapsulateContext;
/**
* 生成workflow的json
* @param nodes
* @param options
* @returns
*/
async generateWorkflowJSON(
nodes: WorkflowNodeEntity[],
options,
): Promise<WorkflowJSON> {
const ports =
this.encapsulateLinesService.getValidEncapsulateConnectPorts(nodes);
const json = await this.workflowDocument.toJSON();
const nodeIds = nodes.map(node => node.id);
// step 1: 生成json
const defaultJSON = this.defaultJSON();
const startNode = defaultJSON.nodes.find(
node => node.type === StandardNodeType.Start,
);
const endNode = defaultJSON.nodes.find(
node => node.type === StandardNodeType.End,
);
if (!startNode || !endNode) {
throw new Error('start or end node not found');
}
// step 2: 生成封装节点的json
const parentId = getNodesParentId(nodes);
const parentNodes =
parentId === 'root'
? json.nodes
: this.findSubCanvasNodeJSON(json.nodes, parentId)?.blocks || [];
const encapsulateNodes = parentNodes.filter(node =>
nodeIds.includes(node.id),
);
const parentEdges =
parentId === 'root'
? json.edges
: this.findSubCanvasNodeJSON(json.nodes, parentId)?.edges || [];
const encapsulateEdges = parentEdges.filter(
edge =>
nodeIds.includes(edge.sourceNodeID) &&
nodeIds.includes(edge.targetNodeID),
);
// step 3: 将 start end 连接到封装节点
const { startEdges, endEdges } = this.generateStartEndEdges(
startNode,
endNode,
ports,
);
// step 4: 更新start end 的位置
if (options?.startEndRects) {
setNodePositionByRect(startNode, options.startEndRects.start);
setNodePositionByRect(endNode, options.startEndRects.end);
}
let workflowJSON = {
nodes: [...defaultJSON.nodes, ...encapsulateNodes],
edges: [
...defaultJSON.edges,
...encapsulateEdges,
...startEdges,
...endEdges,
],
};
// step 5: 更新变量引用关系
const vars = this.encapsulateVariableService.getEncapsulateVars(nodes);
workflowJSON = this.encapsulateVariableService.updateVarsInEncapsulateJSON(
workflowJSON,
vars,
);
return workflowJSON;
}
/**
* 生成子流程节点
* @param param0
* @returns
*/
generateSubWorkflowNode = ({
name,
desc,
workflowId,
spaceId,
}: GenerateSubWorkflowNodeOptions) => {
const nodeMeta = this.getTemplateNodeMeta(StandardNodeType.SubWorkflow);
return {
data: {
nodeMeta: {
title: name,
description: desc,
icon: nodeMeta.icon,
isImageflow: false,
},
inputs: {
workflowId,
spaceId,
workflowVersion: this.encapsulateContext.projectId ? '' : 'v0.0.1',
},
},
};
};
/**
* 生成开始结束边
* @param startNode
* @param endNode
* @param ports
* @returns
*/
private generateStartEndEdges(
startNode: WorkflowNodeJSON,
endNode: WorkflowNodeJSON,
ports: ConnectPortsInfo,
) {
const startEdges: WorkflowEdgeJSON[] = uniqBy(
ports.inputLines.map(line => {
const edge = line.toJSON();
edge.sourceNodeID = startNode.id;
delete edge.sourcePortID;
return edge;
}),
this.getCompareEdgeId,
);
const endEdges: WorkflowEdgeJSON[] = uniqBy(
ports.outputLines.map(line => {
const edge = line.toJSON();
edge.targetNodeID = endNode.id;
delete edge.targetPortID;
return edge;
}),
this.getCompareEdgeId,
);
return {
startEdges,
endEdges,
};
}
private getCompareEdgeId(edge) {
return `${edge.sourceNodeID || ''}:${edge.sourcePortID || ''}-${edge.targetNodeID || ''}:${edge.targetPortID || ''}`;
}
/**
* 默认的工作流json
*/
private defaultJSON() {
return {
nodes: [this.generateStartNode(), this.generateEndNode()],
edges: [],
versions: { loop: 'v2' },
};
}
/**
* 生成开始节点
*/
private generateStartNode() {
return {
id: '100001',
type: StandardNodeType.Start,
meta: {
position: { x: 0, y: 0 },
},
data: {
nodeMeta: this.getTemplateNodeMeta(StandardNodeType.Start),
outputs: [],
},
};
}
/**
* 生成结束节点
*/
private generateEndNode() {
return {
id: '900001',
type: StandardNodeType.End,
meta: {
position: { x: 1000, y: 0 },
},
data: {
nodeMeta: this.getTemplateNodeMeta(StandardNodeType.End),
inputs: { terminatePlan: 'returnVariables', inputParameters: [] },
},
};
}
/**
* 获取节点的元数据
*/
private getTemplateNodeMeta(type: StandardNodeType) {
const template = this.encapsulateContext.getNodeTemplate(type);
return template
? {
title: template.title,
subTitle: template.subTitle,
description: template.description,
icon: template.icon,
}
: {};
}
/**
* 查找子工作流对应的json
* @param nodeJSONs
* @param parentId
* @returns
*/
private findSubCanvasNodeJSON(
nodeJSONs: WorkflowNodeJSON[],
parentId: string,
) {
const subCanvasNode = this.findSubCanvasSourceNode(parentId);
return nodeJSONs.find(nodeJSON => nodeJSON.id === subCanvasNode?.id);
}
/**
* 查找子画布对应的节点
* @param subCanvasId
* @returns
*/
private findSubCanvasSourceNode(subCanvasId: string) {
const nodes = this.workflowDocument.getAllNodes();
return nodes.find(node => {
const registry = node.getNodeRegistry() as WorkflowNodeRegistry;
const subCanvas = registry?.meta?.subCanvas;
return subCanvas?.(node)?.canvasNode?.id === subCanvasId;
});
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
export * from './encapsulate-generate-service';

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type WorkflowNodeEntity,
type WorkflowJSON,
type WorkflowNodeJSON,
} from '@flowgram-adapter/free-layout-editor';
import { type Rect } from '../types';
/**
* 生成子流程节点选项
*/
export interface GenerateSubWorkflowNodeOptions {
name: string;
workflowId: string;
desc: string;
spaceId: string;
}
/**
* 封装生成服务
*/
export interface EncapsulateGenerateService {
/**
* 生成流程
* @param nodes
* @returns
*/
generateWorkflowJSON: (
nodes: WorkflowNodeEntity[],
options?: {
startEndRects?: {
start: Rect;
end: Rect;
};
},
) => Promise<WorkflowJSON>;
/**
* 生成子流程节点
* @param options
* @returns
*/
generateSubWorkflowNode: (
options: GenerateSubWorkflowNodeOptions,
) => Partial<WorkflowNodeJSON>;
}
export const EncapsulateGenerateService = Symbol('EncapsulateGenerateService');

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createWorkflowEncapsulatePlugin } from './create-workflow-encapsulate-plugin';
export { EncapsulateService } from './encapsulate';
export { EncapsulatePanel } from './render';
export { ENCAPSULATE_SHORTCUTS } from './render';

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(
navigator.userAgent,
);
const CTRL = isMacOS ? '⌘' : 'Ctrl';
const SHIFT = isMacOS ? '⇧' : 'Shift';
export const ENCAPSULATE_SHORTCUTS = {
encapsulate: `${CTRL} G`,
decapsulate: `${CTRL} ${SHIFT} G`,
};

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type MouseEvent, useRef, type MouseEventHandler } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { useService } from '@flowgram-adapter/free-layout-editor';
import { useValidate } from '../hooks/use-validate';
import { useEncapsulate } from '../hooks/use-encapsulate';
import { EncapsulateTooltip } from '../encapsulate-tooltip';
import { EncapsulateRenderService } from '../encapsulate-render-service';
import { ENCAPSULATE_SHORTCUTS } from '../constants';
import styles from './styles.module.less';
const HOVER_DELAY = 200;
/**
* 封装按钮
*/
export function EncapsulateButton() {
const encapsulateRenderService = useService<EncapsulateRenderService>(
EncapsulateRenderService,
);
const { handleEncapsulate, loading } = useEncapsulate();
const { validating, errors } = useValidate();
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
if (validating) {
return;
}
handleEncapsulate();
};
const hasError = errors && errors.length > 0;
const disabled = !!(!errors || hasError);
const timeOutRef = useRef<number>();
const ref = useRef<HTMLDivElement>(null);
const handleMouseLeave: MouseEventHandler<HTMLDivElement> = () => {
if (!timeOutRef.current) {
timeOutRef.current = window.setTimeout(() => {
encapsulateRenderService.hideTooltip();
timeOutRef.current = undefined;
}, HOVER_DELAY);
}
};
const handleMouseEnter: MouseEventHandler<HTMLDivElement> = () => {
if (timeOutRef.current) {
clearTimeout(timeOutRef.current);
timeOutRef.current = undefined;
}
encapsulateRenderService.showTooltip();
};
return (
<EncapsulateTooltip
errors={errors}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className="pointer-events-auto"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
>
<Button
loading={loading}
disabled={disabled}
className={styles.button}
color="highlight"
onMouseDown={handleClick}
>
<span>
{I18n.t('workflow_encapsulate_button', undefined, '封装工作流')}
</span>
<span className={styles.shortcut}>
{ENCAPSULATE_SHORTCUTS.encapsulate}
</span>
</Button>
</div>
</EncapsulateTooltip>
);
}

View File

@@ -0,0 +1,9 @@
.button {
height: 32px;
}
.shortcut {
// color: rgba(255, 255, 255, 0.6);
margin-left: 4px;
font-family: 'PICO Sans VFE SC, PingFang SC,Noto Sans SC,sans-serif';
}

View File

@@ -0,0 +1,43 @@
@keyframes encapsulate-panel-show {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.encapsulate-panel {
position: absolute;
width: 100%;
top: 16px;
justify-content: center;
align-items: center;
pointer-events: none;
transition: opacity 0.35s ease;
display: flex;
opacity: 0;
&-show {
opacity: 1;
}
.encapsulate-panel-content {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 4px 0 8px;
background-color: var(--coz-bg-max);
border: 1px solid var(--coz-stroke-plus);
border-radius: 10px;
color: var(--coz-fg-primary);
font-weight: 500;
box-shadow: var(--coz-shadow-small);
gap: 16px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState, type FC } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { useService } from '@flowgram-adapter/free-layout-editor';
import { useSelectedNodes } from '../hooks/use-selected-nodes';
import { EncapsulateRenderService } from '../encapsulate-render-service';
import { EncapsulateButton } from '../encapsulate-button';
import { EncapsulateService } from '../../encapsulate';
import styles from './index.module.less';
export const EncapsulatePanel: FC = () => {
const { selectedNodes } = useSelectedNodes();
const { length } = selectedNodes || [];
const [show, setShow] = useState(false);
const encapsulateService = useService<EncapsulateService>(EncapsulateService);
const encapsulateRenderService = useService<EncapsulateRenderService>(
EncapsulateRenderService,
);
useEffect(() => {
const display = encapsulateService.canEncapsulate() && length > 1;
if (!display) {
encapsulateRenderService.hideTooltip();
}
setShow(display);
}, [length]);
return (
<div
className={classNames(styles['encapsulate-panel'], {
[styles['encapsulate-panel-show']]: show,
})}
>
<div className={styles['encapsulate-panel-content']}>
{I18n.t(
'workflow_encapsulate_selecet',
{ length },
`已选中 ${length} 个节点`,
)}{' '}
<EncapsulateButton />
</div>
</div>
);
};

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContainerModule } from 'inversify';
import { bindContributions } from '@flowgram-adapter/free-layout-editor';
import { WorkflowShortcutsContribution } from '@coze-workflow/render';
import { EncapsulateShortcutsContribution } from './encapsulate-shortcuts-contribution';
import { EncapsulateRenderService } from './encapsulate-render-service';
export const EncapsulateRenderContainerModule = new ContainerModule(bind => {
bindContributions(bind, EncapsulateShortcutsContribution, [
WorkflowShortcutsContribution,
]);
bind(EncapsulateRenderService).toSelf().inSingletonScope();
});

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { Emitter } from '@flowgram-adapter/common';
@injectable()
export class EncapsulateRenderService {
private isModalVisible = false;
private onModalVisibleChangeEmitter = new Emitter<boolean>();
readonly onModalVisibleChange = this.onModalVisibleChangeEmitter.event;
private isTooltipVisible = false;
private onTooltipVisibleChangeEmitter = new Emitter<boolean>();
readonly onTooltipVisibleChange = this.onTooltipVisibleChangeEmitter.event;
private isLoading = false;
private onLoadingChangeEmitter = new Emitter<boolean>();
readonly onLoadingChange = this.onLoadingChangeEmitter.event;
get modalVisible() {
return this.isModalVisible;
}
get tooltipVisible() {
return this.isTooltipVisible;
}
get loading() {
return this.isLoading;
}
setLoading(value: boolean) {
this.isLoading = value;
this.onLoadingChangeEmitter.fire(value);
}
openModal() {
this.setModalVisible(true);
}
closeModal() {
this.setModalVisible(false);
}
showTooltip() {
this.setTooltipVisible(true);
}
hideTooltip() {
this.setTooltipVisible(false);
}
setTooltipVisible(value: boolean) {
this.isTooltipVisible = value;
this.onTooltipVisibleChangeEmitter.fire(value);
}
private setModalVisible(value: boolean) {
this.isModalVisible = value;
this.onModalVisibleChangeEmitter.fire(value);
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowShortcutsContribution,
type WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { EncapsulateService } from '../encapsulate';
import { EncapsulateCommands } from './types';
import { EncapsulateRenderService } from './encapsulate-render-service';
/**
* 封装 快捷键
*/
@injectable()
export class EncapsulateShortcutsContribution
implements WorkflowShortcutsContribution
{
@inject(PlaygroundConfigEntity)
private playgroundConfigEntity: PlaygroundConfigEntity;
@inject(EncapsulateService)
private encapsulateService: EncapsulateService;
@inject(EncapsulateRenderService)
private encapsulateRenderService: EncapsulateRenderService;
@inject(WorkflowSelectService)
private workflowSelectService: WorkflowSelectService;
registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers(
/**
* 封装
*/
{
commandId: EncapsulateCommands.ENCAPSULATE,
shortcuts: ['meta g', 'ctrl g'],
isEnabled: () => !this.playgroundConfigEntity.readonly,
execute: async () => {
if (!this.encapsulateService.canEncapsulate()) {
return;
}
const res = await this.encapsulateService.validate();
if (res.hasError()) {
this.encapsulateRenderService.showTooltip();
return;
}
this.encapsulateRenderService.setLoading(true);
try {
await this.encapsulateService.encapsulate();
this.encapsulateRenderService.closeModal();
} catch (e) {
console.error(e);
}
this.encapsulateRenderService.setLoading(false);
},
},
/**
* 解封
*/
{
commandId: EncapsulateCommands.DECAPSULATE,
shortcuts: ['meta shift g', 'ctrl shift g'],
isEnabled: () => !this.playgroundConfigEntity.readonly,
execute: () => {
const { selectedNodes } = this.workflowSelectService;
if (selectedNodes.length !== 1) {
return;
}
const node = selectedNodes[0] as WorkflowNodeEntity;
if (!this.encapsulateService.canDecapsulate(node)) {
return;
}
this.encapsulateService.decapsulate(node);
},
},
);
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import classNames from 'classnames';
import { Typography } from '@coze-arch/coze-design';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
usePlayground,
useService,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { type EncapsulateValidateError } from '../../validate';
import styles from './index.module.less';
interface Props {
error: EncapsulateValidateError;
}
export const ErrorTitle: FC<Props> = ({ error }) => {
const selectServices = useService<WorkflowSelectService>(
WorkflowSelectService,
);
const playground = usePlayground();
if (!error?.sourceName && !error.sourceIcon) {
return <div></div>;
}
const scrollToNode = async (nodeId: string) => {
let success = false;
const node = playground.entityManager.getEntityById<FlowNodeEntity>(nodeId);
if (node) {
await selectServices.selectNodeAndScrollToView(node, true);
success = true;
}
return success;
};
return (
<div
className="flex items-center gap-1 cursor-pointer max-w-[120px]"
onClick={() => {
if (error.source) {
scrollToNode(error.source);
}
}}
>
{error.sourceIcon ? (
<img
width={18}
height={18}
src={error.sourceIcon}
className="w-4.5 h-4.5 rounded-[4px]"
/>
) : null}
{error.sourceName ? (
<Typography.Paragraph
className={classNames(
'font-medium coz-fg-primary',
styles['error-name'],
)}
ellipsis={{
rows: 1,
showTooltip: {
type: 'tooltip',
opts: {
style: {
width: '100%',
wordBreak: 'break-word',
},
},
},
}}
>
{error.sourceName}
</Typography.Paragraph>
) : null}
</div>
);
};

View File

@@ -0,0 +1,20 @@
.error-name {
span {
font-weight: 500;
}
}
.tooltip {
max-width: 460px;
max-height: 260px;
overflow-y: auto;
padding: 8px 12px;
}
.errors {
display: grid;
grid-template-columns: minmax(0, max-content) 1fr;
grid-gap: 16px;
grid-row-gap: 12px;
margin-top: 12px;
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
useEffect,
useState,
type PropsWithChildren,
type FC,
type MouseEventHandler,
useMemo,
} from 'react';
import { groupBy } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/coze-design';
import { useService } from '@flowgram-adapter/free-layout-editor';
import { EncapsulateRenderService } from '../encapsulate-render-service';
import { type EncapsulateValidateError } from '../../validate';
import { ErrorTitle } from './error-title';
import styles from './index.module.less';
interface Props {
errors: EncapsulateValidateError[];
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
getPopupContainer?: () => HTMLElement;
}
const ErrorMessage: FC<{
error: EncapsulateValidateError;
}> = ({ error }) => (
<div className="flex-1 coz-fg-primary font-normal">{error.message}</div>
);
export const EncapsulateTooltip: FC<PropsWithChildren<Props>> = ({
errors = [],
onMouseEnter,
onMouseLeave,
children,
}) => {
const encapsulateRenderService = useService<EncapsulateRenderService>(
EncapsulateRenderService,
);
const [tooltipVisible, setTooltipVisible] = useState(
encapsulateRenderService.tooltipVisible,
);
useEffect(() => {
const disposable =
encapsulateRenderService.onTooltipVisibleChange(setTooltipVisible);
return () => {
disposable.dispose();
};
}, []);
const hasError = errors.length;
const groupErrors = useMemo(
() =>
groupBy(
errors.filter(e => e.message),
error =>
error?.sourceName || error?.sourceIcon
? 'withSource'
: 'withoutSource',
),
[errors],
);
return (
<Tooltip
trigger="custom"
position="bottom"
visible={tooltipVisible && errors?.length > 0}
showArrow={false}
onClickOutSide={() => {
setTooltipVisible(false);
}}
className="p-0 max-w-[460px] overflow-hidden"
content={
hasError ? (
<div
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={styles.tooltip}
>
<div className="coz-fg-plus font-medium text-[16px]">
{I18n.t(
'workflow_encapsulate_button_unable',
undefined,
'无法封装工作流',
)}
</div>
{/* 没有错误来源的 */}
{(groupErrors.withoutSource || []).map((error, index) => (
<div key={index} className="flex mt-3 gap-4 items-start">
<ErrorMessage error={error} />
</div>
))}
{/* 有错误来源的 */}
{(groupErrors.withSource || []).length ? (
<div className={styles.errors}>
{(groupErrors.withSource || []).map(error => (
<>
<ErrorTitle error={error} />
<ErrorMessage error={error} />
</>
))}
</div>
) : null}
</div>
) : null
}
>
{children}
</Tooltip>
);
};

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { useService } from '@flowgram-adapter/free-layout-editor';
import { EncapsulateRenderService } from '../encapsulate-render-service';
import { EncapsulateService } from '../../encapsulate';
export const useEncapsulate = () => {
const encapsulateService = useService<EncapsulateService>(EncapsulateService);
const encapsulateRenderService = useService<EncapsulateRenderService>(
EncapsulateRenderService,
);
const [loading, setLoading] = useState(encapsulateRenderService.loading);
useEffect(() => {
const disposable = encapsulateRenderService.onLoadingChange(setLoading);
return () => {
disposable.dispose();
};
}, []);
const handleEncapsulate = async () => {
encapsulateRenderService.setLoading(true);
try {
await encapsulateService.encapsulate();
encapsulateRenderService.closeModal();
} catch (e) {
console.error(e);
}
encapsulateRenderService.setLoading(false);
};
return {
handleEncapsulate,
loading,
};
};

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { useService } from '@flowgram-adapter/free-layout-editor';
import { WorkflowSelectService } from '@flowgram-adapter/free-layout-editor';
/**
* 选中节点
*/
export function useSelectedNodes() {
const selectService = useService<WorkflowSelectService>(
WorkflowSelectService,
);
const [selectedNodes, setSelectedNodes] = useState(
selectService.selectedNodes,
);
useEffect(() => {
const disposable = selectService.onSelectionChanged(() => {
setSelectedNodes(selectService.selectedNodes);
});
return () => {
disposable.dispose();
};
});
return {
selectedNodes,
};
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useRef } from 'react';
import { useDebounceEffect } from 'ahooks';
import { useService } from '@flowgram-adapter/free-layout-editor';
import {
EncapsulateValidateErrorCode,
type EncapsulateValidateError,
} from '../../validate';
import { EncapsulateService } from '../../encapsulate';
import { useVariableChange } from './use-variable-change';
import { useSelectedNodes } from './use-selected-nodes';
const DEBOUNCE_DELAY = 100;
/**
* 校验
*/
export function useValidate() {
const { selectedNodes } = useSelectedNodes();
const encapsulateService = useService<EncapsulateService>(EncapsulateService);
const [validating, setValidating] = useState(false);
const [errors, setErrors] = useState<EncapsulateValidateError[]>([]);
const validationIdRef = useRef(0); // 新增校验ID跟踪
const handleValidate = async () => {
if (selectedNodes.length <= 1) {
return;
}
setValidating(true);
// 生成当前校验ID
const currentValidationId = ++validationIdRef.current;
try {
const validateResult = await encapsulateService.validate();
// 只处理最后一次校验结果
if (currentValidationId === validationIdRef.current) {
setErrors(validateResult.getErrors());
setValidating(false);
}
} catch (error) {
setErrors([
{
code: EncapsulateValidateErrorCode.VALIDATE_ERROR,
message: (error as Error).message,
},
]);
setValidating(false);
}
};
const { version: variableVersion } = useVariableChange(selectedNodes);
useDebounceEffect(
() => {
handleValidate();
},
[selectedNodes, variableVersion],
{
wait: DEBOUNCE_DELAY,
},
);
return {
validating,
errors,
};
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { FlowNodeVariableData } from '@coze-workflow/variable';
export const useVariableChange = nodes => {
const [version, setVersion] = useState(0);
useEffect(() => {
const disposables = nodes
.filter(node => node.getData(FlowNodeVariableData)?.public?.available)
.map(node =>
node.getData(FlowNodeVariableData).public.available.onDataChange(() => {
setVersion(version + 1);
}),
);
return () => {
disposables.forEach(disposable => disposable?.dispose());
};
}, [nodes, version]);
return {
version,
};
};

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
export * from './encapsulate-render-container-module';
export { EncapsulatePanel } from './encapsulate-panel';
export * from './constants';

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* 封装/解封命令
*/
export enum EncapsulateCommands {
/**
* 封装
*/
ENCAPSULATE = 'ENCAPSULATE',
/**
* 解封
*/
DECAPSULATE = 'DECAPSULATE',
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StandardNodeType } from '@coze-workflow/base/types';
import { type WorkflowMode } from '@coze-workflow/base/api';
import { type PluginContext } from '@flowgram-adapter/free-layout-editor';
import { type EncapsulateResult } from './encapsulate';
export interface EncapsulateGlobalState {
spaceId: string;
flowMode: WorkflowMode;
projectId?: string;
info: {
name?: string;
};
}
export interface NodeMeta {
description: string;
icon: string;
subTitle: string;
title: string;
}
export type GetGlobalStateOption = (
context: PluginContext,
) => EncapsulateGlobalState;
export type GetNodeTemplateOption = (
context: PluginContext,
) => (type: StandardNodeType) => NodeMeta | undefined;
export type OnEncapsulateOption = (
result: EncapsulateResult,
ctx: PluginContext,
) => Promise<void>;
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare module '*.less' {
const resource: { [key: string]: string };
export = resource;
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getFlags } from '@coze-arch/bot-flags';
/**
* 校验是否可以封装
* @returns 是否可以封装
*/
export function checkEncapsulateGray() {
const FLAGS = getFlags();
return !!FLAGS['bot.automation.encapsulate'];
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StandardNodeType } from '@coze-workflow/base';
import { type WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
/**
* 排除开始和结束节点
* @param nodes
* @returns
*/
export function excludeStartEnd(
nodes: WorkflowNodeEntity[],
): WorkflowNodeEntity[] {
return nodes.filter(
node =>
![StandardNodeType.Start, StandardNodeType.End].includes(
node.flowNodeType as StandardNodeType,
),
);
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PositionData } from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowNodeJSON,
WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
/**
* 获取节点坐标
* @param node
* @returns
*/
export function getNodePoint(
node: WorkflowNodeEntity | WorkflowNodeJSON,
): IPoint {
if (node instanceof WorkflowNodeEntity) {
const positionData = node.getData<PositionData>(PositionData);
return {
x: positionData.x,
y: positionData.y,
};
}
return node?.meta?.position || { x: 0, y: 0 };
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
/**
* 获取节点的父节点ID
*/
export const getNodesParentId = (nodes: FlowNodeEntity[]): string =>
nodes[0]?.parent?.id || 'root';

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { uniq } from 'lodash-es';
import {
type WorkflowNodeRegistry,
type FlowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
/**
* 获取有子节点的节点列表
* @param nodes
* @returns
*/
export const getNodesWithSubCanvas = (nodes: FlowNodeEntity[]) =>
uniq(
nodes
.map(node => {
const registry = node.getNodeRegistry() as WorkflowNodeRegistry;
const subCanvas = registry?.meta?.subCanvas;
return [
node,
// 子画布对应的所有子节点
...(subCanvas?.(node)?.canvasNode?.allCollapsedChildren || []),
];
})
.flat(),
);

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FlowNodeFormData } from '@flowgram-adapter/free-layout-editor';
import { type WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
interface SubWorkflowInfo {
spaceId: string;
workflowId: string;
workflowVersion: string;
}
/**
* 获取子流程信息
* @param node 子流程节点
* @returns spaceId 和 workflowId
*/
export function getSubWorkflowInfo(
node: WorkflowNodeEntity,
): SubWorkflowInfo | undefined {
const formData = node.getData<FlowNodeFormData>(FlowNodeFormData);
const formItem = formData?.formModel.getFormItemValueByPath('/inputs');
if (!formItem) {
return;
}
return {
spaceId: formItem.spaceId,
workflowId: formItem.workflowId,
workflowVersion: formItem.workflowVersion,
};
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type WorkflowNodeRegistry,
type WorkflowNodeJSON,
type WorkflowDocument,
} from '@flowgram-adapter/free-layout-editor';
/**
* 是否包含有子画布的节点
*/
export const hasSubCanvasNodes = (
workflowDocument: WorkflowDocument,
nodes: WorkflowNodeJSON[],
) =>
!!nodes.find(node => {
const registry = workflowDocument.getNodeRegister(
node.type,
) as WorkflowNodeRegistry;
return !!registry?.meta?.subCanvas;
});

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './check-encapsulate-gray';
export * from './get-sub-workflow-info';
export * from './get-node-point';
export * from './set-node-position';

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { customAlphabet } from 'nanoid';
const RANDOM_NUM = 6;
const RANDOM_ALPHABET = '0123456789';
export const randomNameSuffix = () => {
const nanoid = customAlphabet(RANDOM_ALPHABET, RANDOM_NUM);
return nanoid();
};

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type WorkflowNodeJSON } from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
import { type Rect } from '../types';
/**
* 设置节点坐标
* @param node
* @returns
*/
export function setNodePosition(
node: WorkflowNodeJSON,
position: IPoint,
): void {
if (!node.meta) {
node.meta = {};
}
node.meta.position = position;
}
/**
* 根据矩形设置节点坐标
* @param node
* @param rect
*/
export function setNodePositionByRect(node: WorkflowNodeJSON, rect: Rect) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
setNodePosition(node, { x: rect.x + rect.width / 2, y: rect.y });
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FlowNodeBaseType,
type FlowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import { type WorkflowSubCanvas } from '@flowgram-adapter/free-layout-editor';
/**
* 多节点是否在子画布中
* @param nodes
* @returns
*/
export const isNodesInSubCanvas = (nodes?: FlowNodeEntity[]) =>
isNodeInSubCanvas(nodes?.[0]);
/**
* 单节点是否在子画布中
* @param nodes
* @returns
*/
export const isNodeInSubCanvas = (node?: FlowNodeEntity) =>
node?.parent?.id !== 'root';
/**
* 是不是子画布节点
* @param node
* @returns
*/
export const isSubCanvasNode = (node?: FlowNodeEntity) =>
node?.flowNodeType === FlowNodeBaseType.SUB_CANVAS;
/**
* 获取子画布的父节点
* @param node
* @returns
*/
export const getSubCanvasParent = (node?: FlowNodeEntity) => {
const nodeMeta = node?.getNodeMeta();
const subCanvas: WorkflowSubCanvas = nodeMeta?.subCanvas(node);
return subCanvas?.parentNode;
};

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isObject, get, has, isArray } from 'lodash-es';
import { type WorkflowVariable } from '@coze-workflow/variable';
import { type DTODefine } from '@coze-workflow/base';
/**
* 遍历 DTO JSON 中的变量引用
* @param data
* @param cb
* @returns
*/
export function traverseRefsInNodeJSON(
data: unknown,
cb: (_ref: DTODefine.RefExpression) => void,
) {
if (isObject(data)) {
// 判断是否符合 ValueExpressionDTO 的结构
if (
get(data, 'type') === 'ref' &&
get(data, 'content.source') === 'block-output' &&
has(data, 'content.blockID') &&
has(data, 'content.name')
) {
cb(data as unknown as DTODefine.RefExpression);
}
Object.entries(data).forEach(([_key, _val]) => {
traverseRefsInNodeJSON(_val, cb);
}, {});
return;
}
if (isArray(data)) {
data.forEach(_item => {
traverseRefsInNodeJSON(_item, cb);
});
}
}
/**
* 变量排序
* @param variable
* @returns
*/
export const variableOrder = (name?: string) => {
const orders = {
USER_INPUT: 2,
CONVERSATION_NAME: 1,
};
return orders[name ?? ''] || 0;
};
export const sortVariables = (variables: WorkflowVariable[]) => {
if (!variables) {
return variables;
}
return variables.sort(
(a, b) =>
variableOrder(b?.viewMeta?.name) - variableOrder(a?.viewMeta?.name),
);
};

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable, multiInject, optional } from 'inversify';
import { type StandardNodeType } from '@coze-workflow/base/types';
import {
EncapsulateNodeValidator,
type EncapsulateValidateManager,
EncapsulateNodesValidator,
EncapsulateWorkflowJSONValidator,
} from './types';
@injectable()
export class EncapsulateValidateManagerImpl
implements EncapsulateValidateManager
{
@multiInject(EncapsulateNodesValidator)
@optional()
private nodesValidators: EncapsulateNodesValidator[] = [];
@multiInject(EncapsulateNodeValidator)
@optional()
private nodeValidators: EncapsulateNodeValidator[] = [];
@multiInject(EncapsulateWorkflowJSONValidator)
@optional()
private workflowJSONValidators: EncapsulateWorkflowJSONValidator[] = [];
getNodeValidators() {
return this.nodeValidators || [];
}
getNodesValidators() {
return this.nodesValidators || [];
}
getWorkflowJSONValidators() {
return this.workflowJSONValidators || [];
}
getNodeValidatorsByType(type: StandardNodeType) {
return (this.nodeValidators || []).filter(validator =>
validator.canHandle(type),
);
}
dispose() {
this.nodeValidators = [];
this.nodesValidators = [];
this.workflowJSONValidators = [];
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import {
type EncapsulateValidateResult,
type EncapsulateValidateErrorCode,
type EncapsulateValidateError,
} from './types';
@injectable()
export class EncapsulateValidateResultImpl
implements EncapsulateValidateResult
{
private errors: Map<string, EncapsulateValidateError[]> = new Map();
addError(error: EncapsulateValidateError) {
if (!this.errors.has(error.code)) {
this.errors.set(error.code, []);
}
const errors = this.errors.get(error.code);
if (errors && !errors.some(item => item.source === error.source)) {
errors.push(error);
}
}
getErrors() {
return [...this.errors.values()].flat();
}
hasError() {
return this.errors.size > 0;
}
hasErrorCode(code: EncapsulateValidateErrorCode) {
return this.errors.has(code);
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { type StandardNodeType } from '@coze-workflow/base/types';
import {
type WorkflowJSON,
type WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import { excludeStartEnd } from '../utils/exclude-start-end';
import { EncapsulateGenerateService } from '../generate';
import {
EncapsulateValidateManager,
type EncapsulateValidateService,
type EncapsulateValidateResult,
EncapsulateValidateResultFactory,
} from './types';
@injectable()
export class EncapsulateValidateServiceImpl
implements EncapsulateValidateService
{
@inject(EncapsulateValidateManager)
private encapsulateValidateManager: EncapsulateValidateManager;
@inject(EncapsulateValidateResultFactory)
private encapsulateValidateResultFactory: EncapsulateValidateResultFactory;
@inject(EncapsulateGenerateService)
private encapsulateGenerateService: EncapsulateGenerateService;
async validate(nodes: WorkflowNodeEntity[]) {
const validateResult: EncapsulateValidateResult =
this.encapsulateValidateResultFactory();
this.validateNodes(nodes, validateResult);
for (const node of nodes) {
await this.validateNode(node, validateResult);
}
if (validateResult.hasError()) {
return validateResult;
}
const workflowJSON =
await this.encapsulateGenerateService.generateWorkflowJSON(
excludeStartEnd(nodes),
);
await this.validateWorkflowJSON(workflowJSON, validateResult);
return validateResult;
}
private async validateWorkflowJSON(
workflowJSON: WorkflowJSON,
validateResult: EncapsulateValidateResult,
) {
const workflowJSONValidators =
this.encapsulateValidateManager.getWorkflowJSONValidators();
await Promise.all(
workflowJSONValidators.map(workflowJSONValidator =>
workflowJSONValidator.validate(workflowJSON, validateResult),
),
);
}
private validateNodes(
nodes: WorkflowNodeEntity[],
validateResult: EncapsulateValidateResult,
) {
const nodesValidators =
this.encapsulateValidateManager.getNodesValidators();
for (const nodesValidator of nodesValidators) {
// 如果节点校验器需要包含起始节点和结束节点,则直接校验
// 否则需要排除起始节点和结束节点
nodesValidator.validate(
nodesValidator.includeStartEnd ? nodes : excludeStartEnd(nodes),
validateResult,
);
}
}
private async validateNode(
node: WorkflowNodeEntity,
validateResult: EncapsulateValidateResult,
) {
const nodeValidators =
this.encapsulateValidateManager.getNodeValidatorsByType(
node.flowNodeType as StandardNodeType,
);
for (const nodeValidator of nodeValidators) {
await nodeValidator.validate(node, validateResult);
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './encapsulate-validate-manager';
export * from './types';
export * from './encapsulate-validate-service';
export * from './encapsulate-validate-result';

View File

@@ -0,0 +1,195 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StandardNodeType } from '@coze-workflow/base/types';
import {
type WorkflowJSON,
type WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
/**
* 封装校验结果
*/
export interface EncapsulateValidateError {
/**
* 错误码
*/
code: EncapsulateValidateErrorCode;
/**
* 错误信息
*/
message: string;
/**
* 错误来源, 如果是节点问题, 就是节点ID
*/
source?: string;
/**
* 错误来源名称
*/
sourceName?: string;
/**
* 来源图标
*/
sourceIcon?: string;
}
/**
* 校验错误码
*/
export enum EncapsulateValidateErrorCode {
NO_START_END = '1001',
INVALID_PORTS = '1002',
ENCAPSULATE_LINES = '1003',
AT_LEAST_TWO_NODES = '1005',
INVALID_FORM = '1006',
VALIDATE_ERROR = '1007',
INVALID_SCHEMA = '1008',
INVALID_LOOP_NODES = '1009',
INVALID_SUB_CANVAS = '1010',
}
/**
* 校验结果
*/
export interface EncapsulateValidateResult {
/**
* 是否有错误
*/
hasError: () => boolean;
/**
* 添加错误
*/
addError: (error: EncapsulateValidateError) => void;
/**
* 获取错误列表
* @returns
*/
getErrors: () => EncapsulateValidateError[];
/**
* 是否有特定code的错误
*/
hasErrorCode: (code: EncapsulateValidateErrorCode) => boolean;
}
export const EncapsulateValidateResult = Symbol('EncapsulateValidateResult');
/**
* 校验结果工厂
*/
export type EncapsulateValidateResultFactory = () => EncapsulateValidateResult;
export const EncapsulateValidateResultFactory = Symbol(
'EncapsulateValidateResultFactory',
);
/**
* 封装节点校验器
*/
export interface EncapsulateNodeValidator {
/**
* 节点类型
*/
canHandle: (type: StandardNodeType) => boolean;
/**
* 节点校验
*/
validate: (
node: WorkflowNodeEntity,
result: EncapsulateValidateResult,
) => void | Promise<void>;
}
export const EncapsulateNodeValidator = Symbol('EncapsulateNodeValidator');
/**
* 所有节点级别的校验器
*/
export interface EncapsulateNodesValidator {
/**
* 所有节点校验
*/
validate: (
nodes: WorkflowNodeEntity[],
result: EncapsulateValidateResult,
) => void;
/**
* 是否包含开始和结束节点
*/
includeStartEnd?: boolean;
}
export const EncapsulateNodesValidator = Symbol('EncapsulateNodesValidator');
/**
* 流程JSON校验器
*/
export interface EncapsulateWorkflowJSONValidator {
validate: (
json: WorkflowJSON,
result: EncapsulateValidateResult,
) => void | Promise<void>;
}
export const EncapsulateWorkflowJSONValidator = Symbol(
'EncapsulateWorkflowJSONValidator',
);
/**
* 封装校验管理
*/
export interface EncapsulateValidateManager {
/**
* 获取所有节点校验器
*/
getNodeValidators: () => EncapsulateNodeValidator[];
/**
* 根据节点类型获取对应的校验器
* @param type
* @returns
*/
getNodeValidatorsByType: (
type: StandardNodeType,
) => EncapsulateNodeValidator[];
/**
* 获取所有流程级别校验器
*/
getNodesValidators: () => EncapsulateNodesValidator[];
/**
* 获取所有流程JSON校验器
* @returns
*/
getWorkflowJSONValidators: () => EncapsulateWorkflowJSONValidator[];
/**
* 销毁
*/
dispose: () => void;
}
export const EncapsulateValidateManager = Symbol('EncapsulateValidateManager');
/**
* 封装校验服务
*/
export interface EncapsulateValidateService {
/**
* 校验
* @param nodes
* @returns
*/
validate: (nodes: WorkflowNodeEntity[]) => Promise<EncapsulateValidateResult>;
}
export const EncapsulateValidateService = Symbol('EncapsulateValidateService');

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { WorkflowNode } from '@coze-workflow/base';
import {
WorkflowDocument,
type WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import { getSubCanvasParent, isSubCanvasNode } from '../utils/subcanvas';
@injectable()
export class EncapsulateBaseValidator {
@inject(WorkflowDocument)
protected workflowDocument: WorkflowDocument;
protected getLineName(from: string, to: string) {
return `${this.getNodeNameById(from)} -> ${this.getNodeNameById(to)}`;
}
protected getLineSource(from: string, to: string) {
return `${from}_${to}`;
}
protected getNodeName(node: WorkflowNodeEntity) {
if (!node) {
return;
}
if (isSubCanvasNode(node)) {
return this.getSubCanvasName(node);
}
const workflowNode = new WorkflowNode(node);
return workflowNode.title || this.defaultNodeName(node.id);
}
protected getSubCanvasName(node: WorkflowNodeEntity) {
const nodeMeta = node.getNodeMeta();
const { title = '' } = nodeMeta?.renderSubCanvas?.() ?? {};
return title || this.defaultNodeName(node.id);
}
protected getSubCanvasIcon(node: WorkflowNodeEntity) {
const parent = getSubCanvasParent(node);
return this.getNodeIcon(parent);
}
protected getNodeIcon(node: WorkflowNodeEntity) {
if (!node) {
return;
}
if (isSubCanvasNode(node)) {
return this.getSubCanvasIcon(node);
}
const workflowNode = new WorkflowNode(node);
return workflowNode.icon;
}
protected getNodeNameById(id: string) {
const node = this.workflowDocument.getNode(id);
if (!node) {
return this.defaultNodeName(id);
}
return this.getNodeName(node);
}
protected defaultNodeName(id: string) {
return `Node${id}`;
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { ValidationService } from '@coze-workflow/base/services';
import { StandardNodeType } from '@coze-workflow/base';
import { type WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
type EncapsulateNodeValidator,
EncapsulateValidateErrorCode,
type EncapsulateValidateResult,
} from '../validate';
import { EncapsulateBaseValidator } from './encapsulate-base-validator';
@injectable()
export class EncapsulateFormValidator
extends EncapsulateBaseValidator
implements EncapsulateNodeValidator
{
@inject(ValidationService)
private validationService: ValidationService;
canHandle(_type: string) {
return true;
}
async validate(node: WorkflowNodeEntity, result: EncapsulateValidateResult) {
// 注释节点不需要校验
if (
[StandardNodeType.Comment].includes(node.flowNodeType as StandardNodeType)
) {
return;
}
const res = await this.validationService.validateNode(node);
if (!res.hasError) {
return;
}
const sourceName = this.getNodeName(node);
const sourceIcon = this.getNodeIcon(node);
const errors = res.nodeErrorMap[node.id] || [];
errors.forEach(error => {
if (!error.errorInfo || error.errorLevel !== 'error') {
return;
}
result.addError({
code: EncapsulateValidateErrorCode.INVALID_FORM,
message: error.errorInfo,
source: node.id,
sourceName,
sourceIcon,
});
});
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { I18n } from '@coze-arch/i18n';
import {
EncapsulateValidateErrorCode,
type EncapsulateNodesValidator,
} from '../validate';
import { EncapsulateLinesService } from '../encapsulate';
@injectable()
export class EncapsulateInputLinesValidator
implements EncapsulateNodesValidator
{
@inject(EncapsulateLinesService)
private encapsulateLinesService: EncapsulateLinesService;
validate(nodes, result) {
const inputLines =
this.encapsulateLinesService.getEncapsulateNodesInputLines(nodes);
if (inputLines.length === 0) {
return;
}
const valid =
this.encapsulateLinesService.validateEncapsulateLines(inputLines);
if (!valid) {
result.addError({
code: EncapsulateValidateErrorCode.ENCAPSULATE_LINES,
message: I18n.t(
'workflow_encapsulate_button_unable_connected',
undefined,
'框选范围内有中间节点连到框选范围外的节点',
),
});
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { I18n } from '@coze-arch/i18n';
import {
EncapsulateValidateErrorCode,
type EncapsulateNodesValidator,
} from '../validate';
import { EncapsulateLinesService } from '../encapsulate';
@injectable()
export class EncapsulateOutputLinesValidator
implements EncapsulateNodesValidator
{
@inject(EncapsulateLinesService)
private encapsulateLinesService: EncapsulateLinesService;
validate(nodes, result) {
const outputLines =
this.encapsulateLinesService.getEncapsulateNodesOutputLines(nodes);
if (outputLines.length === 0) {
return;
}
const valid =
this.encapsulateLinesService.validateEncapsulateLines(outputLines);
if (!valid) {
result.addError({
code: EncapsulateValidateErrorCode.ENCAPSULATE_LINES,
message: I18n.t(
'workflow_encapsulate_button_unable_connected',
undefined,
'框选范围内有中间节点连到框选范围外的节点',
),
});
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { StandardNodeType } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import {
type WorkflowNodeEntity,
WorkflowNodePortsData,
} from '@flowgram-adapter/free-layout-editor';
import {
EncapsulateValidateErrorCode,
type EncapsulateNodesValidator,
} from '../validate';
import { getNodesWithSubCanvas } from '../utils/get-nodes-with-sub-canvas';
import { EncapsulateBaseValidator } from './encapsulate-base-validator';
@injectable()
export class EncapsulatePortsValidator
extends EncapsulateBaseValidator
implements EncapsulateNodesValidator
{
validate(nodes: WorkflowNodeEntity[], result) {
getNodesWithSubCanvas(nodes).forEach(node => {
const ignoreNodes = [
StandardNodeType.Comment,
FlowNodeBaseType.SUB_CANVAS,
];
if (ignoreNodes.includes(node.flowNodeType as StandardNodeType)) {
return;
}
const portsData = node.getData<WorkflowNodePortsData>(
WorkflowNodePortsData,
);
const hasNotConnectPort = portsData.allPorts.some(
port => port.lines.length === 0,
);
if (hasNotConnectPort) {
const sourceName = this.getNodeName(node);
const sourceIcon = this.getNodeIcon(node);
result.addError({
code: EncapsulateValidateErrorCode.INVALID_PORTS,
message: I18n.t(
'workflow_encapsulate_button_unable_uncomplete',
undefined,
'封装不应该包含没有输入输出的节点',
),
source: node.id,
sourceName,
sourceIcon,
});
}
});
}
includeStartEnd: true;
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { type WorkflowJSON } from '@flowgram-adapter/free-layout-editor';
import {
EncapsulateValidateErrorCode,
type EncapsulateValidateError,
type EncapsulateValidateResult,
type EncapsulateWorkflowJSONValidator,
} from '../validate';
import { EncapsulateApiService } from '../api';
import { EncapsulateBaseValidator } from './encapsulate-base-validator';
@injectable()
export class EncapsulateSchemaValidator
extends EncapsulateBaseValidator
implements EncapsulateWorkflowJSONValidator
{
@inject(EncapsulateApiService)
private encapsulateApiService: EncapsulateApiService;
async validate(workflow: WorkflowJSON, result: EncapsulateValidateResult) {
const validateResult =
await this.encapsulateApiService.validateWorkflow(workflow);
if (!validateResult?.length) {
return;
}
const errors = validateResult || [];
errors.forEach(error => {
const nodeId = error.node_error?.node_id || error.path_error?.start || '';
const node = this.workflowDocument.getNode(nodeId);
let sourceName: EncapsulateValidateError['sourceName'] = undefined;
let sourceIcon: EncapsulateValidateError['sourceIcon'] = undefined;
let source: EncapsulateValidateError['source'] = undefined;
if (node) {
sourceName = this.getNodeName(node);
sourceIcon = this.getNodeIcon(node);
source = node.id;
}
result.addError({
code: EncapsulateValidateErrorCode.INVALID_SCHEMA,
message: error.message || '',
source,
sourceName,
sourceIcon,
});
});
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContainerModule } from 'inversify';
import { bindContributions } from '@flowgram-adapter/free-layout-editor';
import {
EncapsulateNodesValidator,
EncapsulateWorkflowJSONValidator,
EncapsulateNodeValidator,
} from '../validate';
import { SubCanvasValidator } from './sub-canvas-validator';
import { StartEndValidator } from './start-end-validator';
import { LoopNodesValidator } from './loop-nodes-validator';
import { EncapsulateSchemaValidator } from './encapsulate-schema-validator';
import { EncapsulatePortsValidator } from './encapsulate-ports-validator';
import { EncapsulateOutputLinesValidator } from './encapsulate-output-lines-validator';
import { EncapsulateInputLinesValidator } from './encapsulate-input-lines-validator';
import { EncapsulateFormValidator } from './encapsulate-form-validator';
export const EncapsulateValidatorsContainerModule = new ContainerModule(
bind => {
// json validators
bindContributions(bind, EncapsulateSchemaValidator, [
EncapsulateWorkflowJSONValidator,
]);
// nodes validators
[
EncapsulatePortsValidator,
EncapsulateInputLinesValidator,
EncapsulateOutputLinesValidator,
StartEndValidator,
LoopNodesValidator,
SubCanvasValidator,
].forEach(Validator => {
bindContributions(bind, Validator, [EncapsulateNodesValidator]);
});
// node validator
[EncapsulateFormValidator].forEach(Validator => {
bindContributions(bind, Validator, [EncapsulateNodeValidator]);
});
},
);

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './encapsulate-validators-container-module';
export { EncapsulateBaseValidator } from './encapsulate-base-validator';

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { StandardNodeType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import {
EncapsulateValidateErrorCode,
type EncapsulateNodesValidator,
} from '../validate';
@injectable()
export class LoopNodesValidator implements EncapsulateNodesValidator {
validate(nodes, result) {
const filtered = nodes.filter(node =>
[StandardNodeType.Break, StandardNodeType.Continue].includes(
node.flowNodeType,
),
);
if (filtered.length) {
result.addError({
code: EncapsulateValidateErrorCode.INVALID_LOOP_NODES,
message: I18n.t(
'workflow_encapsulate_button_unable_continue_or_teiminate',
undefined,
'框选范围内包含继续循环/终止循环',
),
});
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { StandardNodeType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import {
EncapsulateValidateErrorCode,
type EncapsulateNodesValidator,
} from '../validate';
@injectable()
export class StartEndValidator implements EncapsulateNodesValidator {
validate(nodes, result) {
const filtered = nodes.filter(node =>
[StandardNodeType.Start, StandardNodeType.End].includes(
node.flowNodeType,
),
);
if (filtered.length) {
result.addError({
code: EncapsulateValidateErrorCode.NO_START_END,
message: I18n.t(
'workflow_encapsulate_button_unable_start_or_end',
undefined,
'框选范围内包含开始/结束',
),
});
}
}
includeStartEnd = true;
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import { I18n } from '@coze-arch/i18n';
import { type WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { getSubCanvasParent, isSubCanvasNode } from '@/utils/subcanvas';
import {
type EncapsulateNodesValidator,
EncapsulateValidateErrorCode,
type EncapsulateValidateResult,
} from '../validate';
import { EncapsulateBaseValidator } from './encapsulate-base-validator';
@injectable()
export class SubCanvasValidator
extends EncapsulateBaseValidator
implements EncapsulateNodesValidator
{
validate(nodes: WorkflowNodeEntity[], result: EncapsulateValidateResult) {
nodes
.filter(node => isSubCanvasNode(node))
.forEach(subCanvasNode => {
const parent = getSubCanvasParent(subCanvasNode);
if (!parent) {
return;
}
const sourceName = this.getNodeName(subCanvasNode);
const sourceIcon = this.getNodeIcon(subCanvasNode);
if (!nodes.includes(parent)) {
result.addError({
code: EncapsulateValidateErrorCode.INVALID_SUB_CANVAS,
message: I18n.t('workflow_encapsulate_button_unable_loop_or_batch'),
source: subCanvasNode.id,
sourceName,
sourceIcon,
});
}
});
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContainerModule } from 'inversify';
import {
EncapsulateValidateService,
EncapsulateValidateServiceImpl,
EncapsulateValidateManager,
EncapsulateValidateManagerImpl,
EncapsulateValidateResult,
EncapsulateValidateResultImpl,
EncapsulateValidateResultFactory,
} from './validate';
import {
EncapsulateGenerateService,
EncapsulateGenerateServiceImpl,
} from './generate';
import { EncapsulateContext } from './encapsulate-context';
import {
EncapsulateNodesService,
EncapsulateService,
EncapsulateServiceImpl,
EncapsulateManager,
EncapsulateManagerImpl,
EncapsulateLinesService,
EncapsulateVariableService,
} from './encapsulate';
import { EncapsulateApiService, EncapsulateApiServiceImpl } from './api';
export const WorkflowEncapsulateContainerModule = new ContainerModule(bind => {
// encapsulate
bind(EncapsulateService).to(EncapsulateServiceImpl).inSingletonScope();
bind(EncapsulateManager).to(EncapsulateManagerImpl).inSingletonScope();
bind(EncapsulateNodesService).toSelf().inSingletonScope();
bind(EncapsulateLinesService).toSelf().inSingletonScope();
bind(EncapsulateVariableService).toSelf().inSingletonScope();
// validate
bind(EncapsulateValidateService)
.to(EncapsulateValidateServiceImpl)
.inSingletonScope();
bind(EncapsulateValidateManager)
.to(EncapsulateValidateManagerImpl)
.inSingletonScope();
bind(EncapsulateValidateResult)
.to(EncapsulateValidateResultImpl)
.inTransientScope();
bind(EncapsulateValidateResultFactory).toFactory<EncapsulateValidateResult>(
context => () =>
context.container.get<EncapsulateValidateResult>(
EncapsulateValidateResult,
),
);
// generate
bind(EncapsulateGenerateService)
.to(EncapsulateGenerateServiceImpl)
.inSingletonScope();
// save
bind(EncapsulateApiService).to(EncapsulateApiServiceImpl).inSingletonScope();
// context
bind(EncapsulateContext).toSelf().inSingletonScope();
});