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,61 @@
/*
* 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 IntelligenceType } from '@coze-arch/idl/intelligence_api';
import { Emitter, type Event } from '@flowgram-adapter/common';
/**
* chatflow testrun 选中的 item 信息
*/
export interface SelectItem {
name: string;
value: string;
avatar: string;
type: IntelligenceType;
}
export interface ConversationItem {
label: string;
value: string;
conversationId: string;
}
@injectable()
export class ChatflowService {
selectItem?: SelectItem;
selectConversationItem?: ConversationItem;
onSelectItemChangeEmitter = new Emitter<SelectItem | undefined>();
onSelectItemChange: Event<SelectItem | undefined> =
this.onSelectItemChangeEmitter.event;
onSelectConversationItemChangeEmitter = new Emitter<
ConversationItem | undefined
>();
onSelectConversationItemChange: Event<ConversationItem | undefined> =
this.onSelectConversationItemChangeEmitter.event;
setSelectItem(selectItem?: SelectItem) {
this.selectItem = selectItem;
this.onSelectItemChangeEmitter.fire(selectItem);
}
setSelectConversationItem(conversationItem?: ConversationItem) {
this.selectConversationItem = conversationItem;
this.onSelectConversationItemChangeEmitter.fire(conversationItem);
}
}

View File

@@ -0,0 +1,322 @@
/*
* 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 @typescript-eslint/no-explicit-any */
import { createWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/shallow';
import { set, get } from 'lodash-es';
import { injectable, inject } from 'inversify';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
type DatabaseSettingField,
type DatabaseSettingFieldDTO,
type DatabaseConditionDTO,
type DatabaseCondition,
ValueExpressionType,
ConditionLogicDTO,
ConditionLogic,
type ConditionOperator,
workflowQueryClient,
} from '@coze-workflow/base';
import { type SingleDatabaseResponse } from '@coze-arch/idl/memory';
import { I18n } from '@coze-arch/i18n';
import { MemoryApi } from '@coze-arch/bot-api';
import { ValueExpressionService } from './value-expression-service';
import { type DatabaseNodeService } from './database-node-service';
const STALE_TIME = 20000;
interface DatabaseNodeServiceState {
/**
* 数据库数据是否正在加载中
*/
loading: boolean;
/**
*/
data: Record<string, SingleDatabaseResponse>;
/**
*/
error: Record<string, string | undefined>;
}
interface DatabasenNodeServiceAction {
getData: (id: string) => SingleDatabaseResponse;
setData: (id: string, value: SingleDatabaseResponse) => void;
getError: (id: string) => string | undefined;
setError: (id: string, value: string | undefined) => void;
clearError: (id: string) => void;
}
export type DatabseNodeStore = DatabaseNodeServiceState &
DatabasenNodeServiceAction;
const createStore = () =>
createWithEqualityFn<DatabseNodeStore>(
(setStore, getStore) => ({
loading: false,
data: {},
error: {},
getData(id) {
return getStore().data[id];
},
setData(id, value) {
setStore({
data: {
...getStore().data,
[id]: value,
},
});
},
getError(id) {
return getStore().error[id];
},
setError(id, value) {
setStore({
error: {
...getStore().error,
[id]: value,
},
});
},
clearError(id) {
setStore({
error: {
...getStore().error,
[id]: undefined,
},
});
},
}),
shallow,
);
@injectable()
export class DatabaseNodeServiceImpl implements DatabaseNodeService {
store = createStore();
@inject(ValueExpressionService)
private readonly valueExpressionService: ValueExpressionService;
public convertSettingFieldToDTO(
name: string,
value: any,
node: FlowNodeEntity,
) {
const databaseSettingField = get(value, name);
if (!databaseSettingField) {
return value;
}
const databaseSettingFieldDTO: DatabaseSettingFieldDTO[] = (
databaseSettingField as DatabaseSettingField[]
).map(field => {
const { fieldID, fieldValue = { type: ValueExpressionType.LITERAL } } =
field;
return [
{
name: 'fieldID',
input: {
type: 'string',
value: {
type: ValueExpressionType.LITERAL,
content: fieldID.toString(),
},
},
},
{
name: 'fieldValue',
input: this.valueExpressionService.toDTO(fieldValue, node),
},
];
});
set(value, name, databaseSettingFieldDTO);
return value;
}
public convertSettingFieldDTOToField(name: string, value: any) {
const databaseSettingFieldDTO = get(value, name);
if (!databaseSettingFieldDTO) {
return value;
}
const databaseSettingField: DatabaseSettingField[] = (
databaseSettingFieldDTO as DatabaseSettingFieldDTO[]
).map(field => ({
fieldID: Number(field[0].input.value.content),
fieldValue: this.valueExpressionService.toVO(field[1]?.input),
}));
set(value, name, databaseSettingField);
return value;
}
public convertConditionDTOToCondition(name: string, value: any) {
const conditionDTO = get(value, name);
if (!conditionDTO) {
return value;
}
const condition: DatabaseCondition[] = conditionDTO.map(
([left, operator, right]) => ({
left: left?.input.value.content,
operator: operator?.input.value.content,
right: this.valueExpressionService.toVO(right?.input),
}),
);
set(value, name, condition);
return value;
}
public convertConditionToDTO(name: string, value: any, node: FlowNodeEntity) {
const condition: DatabaseCondition[] = get(value, name);
if (!condition) {
return value;
}
const conditionDTO: DatabaseConditionDTO[] = condition.map(
({ left, operator, right }) => [
left
? {
name: 'left',
input: {
type: 'string',
value: {
type: ValueExpressionType.LITERAL,
content: left,
},
},
}
: undefined,
operator
? {
// 对操作符的翻译前后端没有统一,所以这里暂时用 operation 代替
name: 'operation',
input: {
type: 'string',
value: {
type: ValueExpressionType.LITERAL,
content: operator,
},
},
}
: undefined,
right
? {
name: 'right',
input: this.valueExpressionService.toDTO(right, node),
}
: undefined,
],
);
set(value, name, conditionDTO);
return value;
}
public convertConditionLogicDTOToConditionLogic(name: string, value: any) {
const conditionLogicDTO: ConditionLogicDTO = get(value, name);
const conditionLogic: ConditionLogic =
conditionLogicDTO === ConditionLogicDTO.AND
? ConditionLogic.AND
: ConditionLogic.OR;
set(value, name, conditionLogic);
return value;
}
public convertConditionLogicToConditionLogicDTO(name: string, value: any) {
const conditionLogic: ConditionLogic = get(value, name);
const conditionLogicDTO: ConditionLogicDTO =
conditionLogic === ConditionLogic.AND
? ConditionLogicDTO.AND
: ConditionLogicDTO.OR;
set(value, name, conditionLogicDTO);
return value;
}
public checkConditionOperatorNoNeedRight(
conditionOperator?: ConditionOperator,
) {
return ['IS_NULL', 'IS_NOT_NULL', 'BE_TRUE', 'BE_FALSE'].includes(
conditionOperator || '',
);
}
get state() {
return this.store.getState();
}
set loading(v: boolean) {
this.store.setState({
loading: v,
});
}
async load(id: string) {
if (!id) {
return;
}
try {
this.loading = true;
const data = await workflowQueryClient.fetchQuery({
queryKey: ['MemoryApi.GetDatabaseByID', id],
queryFn: async () =>
await MemoryApi.GetDatabaseByID({ id, need_sys_fields: true }),
staleTime: STALE_TIME,
});
this.state.setData(id, data);
} catch (error) {
this.state.setError(id, error);
this.state.setData(id, {
database_info: {
id,
table_name: I18n.t('invalid_database', {}, '无效数据库'),
},
BaseResp: {},
});
} finally {
this.loading = false;
}
}
clearDatabaseError(id: string) {
this.state.clearError(id);
}
}

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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { type ConditionOperator } from '@coze-workflow/base';
export abstract class DatabaseNodeService {
/**
* 将数据库设置字段转换为数据库设置字段DTO。
* @param name 数据库设置字段DTO的名称
* @param value 整个表单数据
*/
abstract convertSettingFieldToDTO(
name: string,
value: any,
node: FlowNodeEntity,
): any;
/**
* 将数据库设置字段DTO转换为数据库设置字段。
* @param name 数据库设置字段DTO的名称
* @param value 整个表单数据
*/
abstract convertSettingFieldDTOToField(name: string, value: any): any;
/**
* 将表单中的条件DTO转换为条件
* @param name 条件DTO的名称
* @param value 整个表单数据
*/
abstract convertConditionDTOToCondition(name: string, value: any): any;
/**
* 将条件逻辑DTO转换为条件逻辑。
* @param name 条件逻辑DTO的名称
* @param value 条件逻辑DTO的值
* @returns value 整个表单数据
*/
abstract convertConditionLogicDTOToConditionLogic(
name: string,
value: any,
): any;
/**
* 将条件逻辑转换为条件逻辑DTO。
* @param name 条件逻辑的名称
* @param value 条件逻辑的值
* @returns value 整个表单数据
*/
abstract convertConditionLogicToConditionLogicDTO(
name: string,
value: any,
): any;
/**
* 将条件转换为条件DTO
* @param name 条件的名称
* @param value 整个表单数据
*/
abstract convertConditionToDTO(
name: string,
value: any,
node: FlowNodeEntity,
): any;
/**
* 判断当前条件是否不需要右值
* @param condition 当前条件数据
* @returns 如果条件不需要左值则返回true否则返回false
*/
abstract checkConditionOperatorNoNeedRight(
conditionOperator?: ConditionOperator,
): boolean;
abstract store: any;
/**
* 查询当前数据库数据
*/
abstract load(id: string): void;
/**
* 清空数据库缓存的错误信息
*/
abstract clearDatabaseError(id: string): void;
}

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.
*/
export { WorkflowRunService } from './workflow-run-service';
export { TestRunReporterService } from './test-run-reporter-service';
export { WorkflowEditService } from './workflow-edit-service';
export { WorkflowSaveService } from './workflow-save-service';
export { RoleService } from './role-service';
export { RelatedCaseDataService } from './related-case-data-service';
export { ChatflowService } from './chatflow-service';
export { NodeVersionService } from './node-version-service';
export { WorkflowCustomDragService } from './workflow-drag-service';
export { WorkflowOperationService } from './workflow-operation-service';
export { WorkflowValidationService } from './workflow-validation-service';
export { WorkflowModelsService } from './workflow-models-service';
export { WorkflowFloatLayoutService } from './workflow-float-layout-service';
export { ValueExpressionService } from './value-expression-service';
export { ValueExpressionServiceImpl } from './value-expression-service-impl';
export { DatabaseNodeService } from './database-node-service';
export { DatabaseNodeServiceImpl } from './database-node-service-impl';
export { TriggerService } from './trigger-service';
export { PluginNodeService, type PluginNodeStore } from './plugin-node-service';
export { SubWorkflowNodeService } from '@/node-registries/sub-workflow/services';
export { WorkflowDependencyService } from './workflow-dependency-service';

View File

@@ -0,0 +1,511 @@
/*
* 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 FormModelV2 } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeFormData } from '@flowgram-adapter/free-layout-editor';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { WorkflowDocument } from '@flowgram-adapter/free-layout-editor';
import { WorkflowNodeData } from '@coze-workflow/nodes';
import { workflowApi, StandardNodeType, BlockInput } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Modal } from '@coze-arch/coze-design';
import { type GetApiDetailRequest } from '@coze-arch/bot-api/workflow_api';
import { WorkflowPlaygroundContext } from '@/workflow-playground-context';
import { isNodeV2 } from '@/nodes-v2';
import { type ApiNodeFormData } from '@/node-registries/plugin/types';
import { WorkflowGlobalStateEntity } from '@/entities';
interface NodeWithVersion {
node: FlowNodeEntity;
/**
* 注意 version 可能是不存在的!!!
* 1. 老数据
* 2. 项目中的资源被移动到资源库
*/
/**
* 1. versionName: 形如 v0.0.1,是 wf 的版本索引,也是外显版本号
* 2. versionTs: 字符串形式的时间戳,是 plugin 的版本索引wf 无此概念
*/
version?: string;
}
type SubWorkflowNodeWithVersion = NodeWithVersion & {
workflowId: string;
};
type ApiNodeWithVersion = NodeWithVersion & {
pluginId: string;
apiName: string;
};
const MY_SUB_WORKFLOW_NODE_VERSION_PATH = '/inputs/workflowVersion';
const MY_SUB_WORKFLOW_NODE_ID_PATH = '/inputs/workflowId';
const MY_API_NODE_PARAMS = '/inputs/apiParam';
const LLM_WORKFLOW_FC_PATH = '/fcParam/workflowFCParam/workflowList';
const LLM_API_FC_PATH = '/fcParam/pluginFCParam/pluginList';
export const isApiNode = (node: FlowNodeEntity) =>
StandardNodeType.Api === node.flowNodeType;
export const isSubWorkflowNode = (node: FlowNodeEntity) =>
StandardNodeType.SubWorkflow === node.flowNodeType;
export const isLLMNode = (node: FlowNodeEntity) =>
node.flowNodeType === StandardNodeType.LLM;
export const getNodeFormModelV2 = (node: FlowNodeEntity) => {
const nodeFormV2 = node
.getData<FlowNodeFormData>(FlowNodeFormData)
.getFormModel<FormModelV2>();
return nodeFormV2;
};
export const getNodeFormValue = <T>(node: FlowNodeEntity, path: string): T => {
const nodeForm = node.getData<FlowNodeFormData>(FlowNodeFormData).formModel;
const nodeFormV2 = getNodeFormModelV2(node);
if (isNodeV2(node) && nodeFormV2?.getValueIn) {
return nodeFormV2.getValueIn(path) as T;
}
const value = nodeForm.getFormItemValueByPath(path);
return value;
};
export const getNodeFormModel = (node: FlowNodeEntity, path: string) => {
const nodeForm = node.getData<FlowNodeFormData>(FlowNodeFormData).formModel;
const item = nodeForm.getFormItemByPath(path);
return item;
};
export const getMySubWorkflowNodeVersion = (node: FlowNodeEntity) =>
getNodeFormValue<string | undefined>(node, MY_SUB_WORKFLOW_NODE_VERSION_PATH);
export const getMySubWorkflowNodeId = (node: FlowNodeEntity) =>
getNodeFormValue<string>(node, MY_SUB_WORKFLOW_NODE_ID_PATH);
export const getLLMWorkflowFC = (node: FlowNodeEntity) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNodeFormValue<any[]>(node, LLM_WORKFLOW_FC_PATH);
export const getLLMApiFC = (node: FlowNodeEntity) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNodeFormValue<any[]>(node, LLM_API_FC_PATH);
export const getLLMWorkflowFCById = (
node: FlowNodeEntity,
workflowId: string,
) => {
const workflowList = getLLMWorkflowFC(node) || [];
const current = workflowList.find(w => w.workflow_id === workflowId);
return current;
};
export const getLLMApiFCById = (node: FlowNodeEntity, pluginId: string) => {
const pluginList = getLLMApiFC(node) || [];
const current = pluginList.find(w => w.plugin_id === pluginId);
return current;
};
export const getMyApiNodeParams = (node: FlowNodeEntity) =>
getNodeFormValue<BlockInput[]>(node, MY_API_NODE_PARAMS);
export const getMyApiNodeParam = (node: FlowNodeEntity, name: string) => {
const params = getMyApiNodeParams(node);
const block = params?.find(i => i.name === name);
if (!block) {
return;
}
return BlockInput.toLiteral<string>(block);
};
export const getMyApiNodeId = (node: FlowNodeEntity) =>
getMyApiNodeParam(node, 'pluginID');
export const getMyApiNodeVersion = (node: FlowNodeEntity) =>
getMyApiNodeParam(node, 'pluginVersion');
export const getMyApiNodeName = (node: FlowNodeEntity) =>
getMyApiNodeParam(node, 'apiName');
/**
* 设置子流程节点的版本号
*/
export const setSubWorkflowNodeVersion = (
node: FlowNodeEntity,
version: string,
) => {
const formModelV2 = getNodeFormModelV2(node);
if (isNodeV2(node) && formModelV2?.getValueIn) {
const value = formModelV2.getValueIn<Record<string, unknown>>('/inputs');
formModelV2.setValueIn('/inputs', {
...value,
workflowVersion: version,
});
return;
}
const inputsModel = getNodeFormModel(node, '/inputs');
if (inputsModel) {
inputsModel.value = {
...inputsModel.value,
workflowVersion: version,
};
}
};
export const setApiNodeVersion = (node: FlowNodeEntity, version: string) => {
const formModelV2 = getNodeFormModelV2(node);
if (isNodeV2(node) && formModelV2?.getValueIn) {
const value = formModelV2.getValueIn<ApiNodeFormData['inputs']>('/inputs');
formModelV2.setValueIn('/inputs', {
...value,
apiParam: value?.apiParam.map(i => {
if (i.name === 'pluginVersion') {
return BlockInput.create('pluginVersion', version);
}
return i;
}),
});
return;
}
const inputsModel = getNodeFormModel(node, '/inputs');
if (inputsModel) {
inputsModel.value = {
...inputsModel.value,
apiParam: inputsModel.value.apiParam.map(i => {
if (i.name === 'pluginVersion') {
return BlockInput.create('pluginVersion', version);
}
return i;
}),
};
}
};
const setLLMWorkflowFCVersion = (
node: FlowNodeEntity,
workflowId: string,
version: string,
) => {
const form = getNodeFormModelV2(node);
const value = form.getValueIn(LLM_WORKFLOW_FC_PATH);
if (Array.isArray(value)) {
const idx = value.findIndex(i => i.workflow_id === workflowId);
if (idx > -1) {
value[idx].workflow_version = version;
form.setValueIn(LLM_WORKFLOW_FC_PATH, [...value]);
}
}
};
const setLLMApiFCVersion = (
node: FlowNodeEntity,
pluginId: string,
version: string,
) => {
const form = getNodeFormModelV2(node);
const value = form.getValueIn(LLM_API_FC_PATH);
if (Array.isArray(value)) {
const idx = value.findIndex(i => i.plugin_id === pluginId);
if (idx > -1) {
value[idx].plugin_version = version;
form.setValueIn(LLM_API_FC_PATH, [...value]);
}
}
};
/**
* 获取插件节点的版本信息
*/
export const getApiNodeVersion = (node: FlowNodeEntity) => {
const nodeDataEntity = node.getData<WorkflowNodeData>(WorkflowNodeData);
// 这里用了子流程的 type实际上 api 也是相同
const nodeData = nodeDataEntity.getNodeData<StandardNodeType.Api>();
return {
latestVersionTs: nodeData.latestVersionTs,
latestVersionName: nodeData.latestVersionName,
versionName: nodeData.versionName,
};
};
/**
* 获取子流程节点信息
*/
export const getSubWorkflowNode = (node: FlowNodeEntity) => {
const nodeDataEntity = node.getData<WorkflowNodeData>(WorkflowNodeData);
const nodeData = nodeDataEntity.getNodeData<StandardNodeType.SubWorkflow>();
return nodeData;
};
export const recreateNodeForm = async (
node: FlowNodeEntity,
context: WorkflowPlaygroundContext,
) => {
const formData = node.getData<FlowNodeFormData>(FlowNodeFormData);
const nodeRegistry = node.getNodeRegistry();
// 修改版本后,需要重新请求相应数据,因为相关数据的 key 都是带版本的,会取不到
await nodeRegistry?.onInit?.({ data: formData.toJSON() }, context);
await formData.recreateForm(
node.getNodeRegister().formMeta,
formData.toJSON(),
);
};
const fetchApiNodeVersionName = async (params: GetApiDetailRequest) => {
try {
const { data } = await workflowApi.GetApiDetail(params);
return {
latestVersionName: data?.latest_version_name,
versionName: data?.version_name,
};
} catch {
return {};
}
};
const forceUpdateModel = (oldVersion?: string, newVersion?: string) =>
new Promise<boolean>(resolve =>
Modal.confirm({
title: I18n.t('workflow_version_update_model_title'),
content: I18n.t('workflow_version_add_model_content', {
oldVersion,
newVersion,
}),
okText: I18n.t('confirm'),
cancelText: I18n.t('cancel'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
}),
);
@injectable()
export class NodeVersionService {
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
@inject(WorkflowPlaygroundContext) context: WorkflowPlaygroundContext;
/**
* 遍历流程,获取流程中所有指定 workflowId 的子流程和对应的版本
*/
getSubWorkflowNodesWithVersion(workflowId: string) {
const allNodes = this.document.getAllNodes();
const nodesWithVersion: SubWorkflowNodeWithVersion[] = [];
allNodes.forEach(node => {
// 存在对应的子流程节点
if (
isSubWorkflowNode(node) &&
getMySubWorkflowNodeId(node) === workflowId
) {
nodesWithVersion.push({
node,
workflowId,
version: getMySubWorkflowNodeVersion(node),
});
return;
}
// llm 节点需要下钻到技能中查看是否有引用对应的子流程
if (isLLMNode(node)) {
const wf = getLLMWorkflowFCById(node, workflowId);
if (wf) {
nodesWithVersion.push({
node,
workflowId,
version: wf.workflow_version,
});
}
}
});
return nodesWithVersion;
}
/**
* 遍历流程,获取流程中所有指定 pluginId 的节点和对应的版本
*/
getPluginNodesWithVersion(pluginId: string) {
const allNodes = this.document.getAllNodes();
const nodesWithVersion: ApiNodeWithVersion[] = [];
allNodes.forEach(node => {
if (isApiNode(node) && getMyApiNodeId(node) === pluginId) {
nodesWithVersion.push({
node,
pluginId,
apiName: getMyApiNodeName(node) || '',
version: getMyApiNodeVersion(node),
});
return;
}
if (isLLMNode(node)) {
const p = getLLMApiFCById(node, pluginId);
if (p) {
nodesWithVersion.push({
node,
pluginId,
apiName: p.api_name,
version: p.plugin_version,
});
}
}
});
return nodesWithVersion;
}
async setSubWorkflowNodesVersion(
nodes: SubWorkflowNodeWithVersion[],
version: string,
) {
nodes.forEach(({ node, workflowId }) => {
if (isSubWorkflowNode(node)) {
setSubWorkflowNodeVersion(node, version);
return;
}
if (isLLMNode(node)) {
setLLMWorkflowFCVersion(node, workflowId, version);
return;
}
});
await Promise.all(
nodes.map(({ node }) => recreateNodeForm(node, this.context)),
);
}
async setApiNodesVersion(nodes: ApiNodeWithVersion[], version: string) {
nodes.forEach(({ node, pluginId }) => {
if (isApiNode(node)) {
setApiNodeVersion(node, version);
return;
}
if (isLLMNode(node)) {
setLLMApiFCVersion(node, pluginId, version);
return;
}
});
await Promise.all(
nodes.map(({ node }) => recreateNodeForm(node, this.context)),
);
}
async updateSubWorkflowNodesVersion(workflowId: string, version: string) {
const nodes = this.getSubWorkflowNodesWithVersion(workflowId);
await this.setSubWorkflowNodesVersion(nodes, version);
}
async updateApiNodesVersion(pluginId: string, version: string) {
const nodes = this.getPluginNodesWithVersion(pluginId);
await this.setApiNodesVersion(nodes, version);
}
async updateNodesVersion(node: FlowNodeEntity, version: string) {
if (isSubWorkflowNode(node)) {
await this.updateSubWorkflowNodesVersion(
getMySubWorkflowNodeId(node),
version,
);
return;
}
if (isApiNode(node)) {
await this.updateApiNodesVersion(getMyApiNodeId(node) || '', version);
return;
}
}
nodesCheck<T extends NodeWithVersion>(nodes: T[], targetVersion: string) {
/**
* 需要更新版本的节点有两种:
* 1. 无版本号的节点
* 2. 有版本号,但版本不一致的
*/
const needUpdateNodes = nodes.filter(
({ version }) => version !== targetVersion,
);
/**
* 有版本号的节点更新版本属于有损的强制更新,需要统计出来提示用户。无版本号的节点是无损更新
*/
const needForceNodes = needUpdateNodes.filter(({ version }) => !!version);
const needUpdate = !!needUpdateNodes.length;
const needForce = !!needForceNodes.length;
return {
needForce,
needUpdate,
needUpdateNodes,
needForceNodes,
};
}
async addSubWorkflowCheck(workflowId?: string, targetVersion?: string) {
// 要添加的流程没有版本号,则跳过版本统一步骤
if (!targetVersion || !workflowId) {
return true;
}
const sameNodesWithVersion =
this.getSubWorkflowNodesWithVersion(workflowId);
// 无同一个流程的节点
if (!sameNodesWithVersion.length) {
return true;
}
const { needForce, needUpdate, needForceNodes, needUpdateNodes } =
this.nodesCheck(sameNodesWithVersion, targetVersion);
if (needForce) {
const oldVersion = needForceNodes[0].version as string;
const confirm = await forceUpdateModel(oldVersion, targetVersion);
if (!confirm) {
return false;
}
}
if (needUpdate) {
await this.setSubWorkflowNodesVersion(needUpdateNodes, targetVersion);
}
return true;
}
async addApiCheck(pluginId?: string, targetVersionTs?: string) {
/**
* 1. 版本号不存在,表示引用最新版本,直接跳过验证
* 2. '0' 也视为最新版本,语意上等同于为空,也直接跳过验证
*/
if (!pluginId || !targetVersionTs || targetVersionTs === '0') {
return true;
}
const sameNodesWithVersion = this.getPluginNodesWithVersion(pluginId);
if (!sameNodesWithVersion.length) {
return true;
}
const { needForce, needUpdate, needForceNodes, needUpdateNodes } =
this.nodesCheck(sameNodesWithVersion, targetVersionTs);
if (needForce) {
const oldNode = needForceNodes[0];
const { latestVersionName: targetVersion, versionName: oldVersion } =
await fetchApiNodeVersionName({
pluginID: pluginId,
apiName: oldNode.apiName,
plugin_version: oldNode.version,
space_id: this.globalState.spaceId,
});
const confirm = await forceUpdateModel(
oldVersion || oldNode.version,
targetVersion || targetVersionTs,
);
if (!confirm) {
return false;
}
}
if (needUpdate) {
await this.setApiNodesVersion(needUpdateNodes, targetVersionTs);
}
return true;
}
}

View File

@@ -0,0 +1,262 @@
/*
* 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 { createWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/shallow';
import { inject, injectable } from 'inversify';
import {
type ApiNodeDetailDTO,
type ApiNodeIdentifier,
} from '@coze-workflow/nodes';
import { workflowApi, workflowQueryClient } from '@coze-workflow/base';
import {
PluginProductStatus,
ProductUnlistType,
} from '@coze-arch/idl/developer_api';
import { I18n } from '@coze-arch/i18n';
import { WorkflowGlobalStateEntity } from '@/entities';
interface PluginNodeServiceState {
/**
* 插件节点数据是否正在加载中
*/
loading: boolean;
/**
* 插件节点数据key 为插件具体工具的唯一标识value 为插件节点数据
*/
data: Record<string, ApiNodeDetailDTO>;
/**
* 插件节点数据加载错误信息key 为插件具体工具的唯一标识value 为错误信息
*/
error: Record<string, string | undefined>;
}
interface PluginNodeServiceAction {
getData: (identifier: ApiNodeIdentifier) => ApiNodeDetailDTO;
setData: (identifier: ApiNodeIdentifier, value: ApiNodeDetailDTO) => void;
getError: (identifier: ApiNodeIdentifier) => string | undefined;
setError: (identifier: ApiNodeIdentifier, value: string | undefined) => void;
clearError: (identifier: ApiNodeIdentifier) => void;
}
export type PluginNodeStore = PluginNodeServiceState & PluginNodeServiceAction;
const STALE_TIME = 5000;
function getCacheKey(identifier: ApiNodeIdentifier) {
return `${identifier.pluginID}_${identifier.plugin_version}_${identifier.api_id}`;
}
const createStore = () =>
createWithEqualityFn<PluginNodeServiceState & PluginNodeServiceAction>(
(set, get) => ({
loading: false,
data: {},
error: {},
getData(identifier) {
const key = getCacheKey(identifier);
return get().data[key];
},
setData(identifier, value) {
const key = getCacheKey(identifier);
set({
data: {
...get().data,
[key]: value,
},
});
},
getError(identifier) {
const key = getCacheKey(identifier);
return get().error[key];
},
setError(identifier, value) {
const key = getCacheKey(identifier);
set({
error: {
...get().error,
[key]: value,
},
});
},
clearError(identifier) {
const key = getCacheKey(identifier);
set({
error: {
...get().error,
[key]: undefined,
},
});
},
}),
shallow,
);
@injectable()
export class PluginNodeService {
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
store = createStore();
set loading(v: boolean) {
this.store.setState({
loading: v,
});
}
get state() {
return this.store.getState();
}
getApiDetail(identifier: ApiNodeIdentifier) {
return this.state.getData(identifier);
}
getApiError(identifier: ApiNodeIdentifier) {
return this.state.getError(identifier);
}
clearApiError(identifier: ApiNodeIdentifier) {
this.state.clearError(identifier);
}
async fetchData(identifier: ApiNodeIdentifier) {
const { spaceId, projectId } = this.globalState;
return workflowQueryClient.fetchQuery({
queryKey: [
'loadApiNodeDetail',
spaceId,
identifier.pluginID,
identifier.plugin_version,
identifier.apiName,
identifier.api_id,
projectId,
],
// 1. 设置 5s 缓存,保证一个流程内同请求只发送一次即可,不会产生过多性能劣化
// 2. api detail 包含插件的输入输出、版本信息,数据有实时敏感性,不可数据滞后
staleTime: STALE_TIME,
queryFn: async () =>
await workflowApi.GetApiDetail(
{
...identifier,
space_id: spaceId,
project_id: projectId,
},
{
__disableErrorToast: true,
},
),
});
}
/**
* 插件状态检查,是否失效,判断 ApiNode 是否可用
* @param params 参数
* @returns 是否失效
*/
isApiNodeDeprecated(params: {
currentSpaceID: string;
pluginSpaceID?: string;
pluginProductStatus?: PluginProductStatus;
pluginProductUnlistType?: ProductUnlistType;
}): boolean {
const {
currentSpaceID,
pluginSpaceID,
pluginProductStatus,
pluginProductUnlistType,
} = params;
// 未下架
if (pluginProductStatus !== PluginProductStatus.Unlisted) {
return false;
}
// 被管理员下架
if (pluginProductUnlistType === ProductUnlistType.ByAdmin) {
return true;
}
// 被用户下架但并不是当前插件的创建space
if (
pluginProductUnlistType === ProductUnlistType.ByUser &&
currentSpaceID !== pluginSpaceID
) {
return true;
}
// 被用户下架但处于插件创建space
return false;
}
async load(identifier: ApiNodeIdentifier) {
let apiDetail: ApiNodeDetailDTO | undefined;
let errorMessage = '';
try {
this.loading = true;
const response = await this.fetchData(identifier);
apiDetail = response.data as ApiNodeDetailDTO;
} catch (error) {
errorMessage = error.message;
if (error.code === '702095021') {
errorMessage = I18n.t('workflow_node_lose_efficacy', {
name: identifier.apiName,
});
}
} finally {
this.loading = false;
}
if (errorMessage) {
this.state.setError(identifier, errorMessage);
}
if (!apiDetail) {
this.state.setError(identifier, errorMessage || 'loadApiNode failed');
return;
} else {
this.state.setData(identifier, {
...apiDetail,
// 插件名称如果变更后(例如 getStock 修改成 getStock_v1apiName 不会变还是 getStockname 才是更新后的的 getStock_v1
// 此时需要优先取 name 字段,否则 testrun 时会用老的 apiName导致试运行不成功
apiName: apiDetail.name || apiDetail.apiName,
});
}
const deprecated = this.isApiNodeDeprecated({
currentSpaceID: this.globalState.spaceId,
pluginSpaceID: apiDetail.spaceID,
pluginProductStatus: apiDetail.pluginProductStatus,
pluginProductUnlistType: apiDetail.pluginProductUnlistType,
});
if (deprecated) {
this.state.setError(
identifier,
I18n.t('workflow_node_lose_efficacy', { name: identifier.apiName }),
);
}
return apiDetail;
}
}

View File

@@ -0,0 +1,217 @@
/*
* 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 { getTestDataByTestset, FieldName } from '@coze-workflow/test-run';
import { StandardNodeType } from '@coze-workflow/base';
import { userStoreService } from '@coze-studio/user-store';
import { IntelligenceType } from '@coze-arch/idl/intelligence_api';
import { ComponentType } from '@coze-arch/idl/debugger_api';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
import { debuggerApi } from '@coze-arch/bot-api';
import { WorkflowGlobalStateEntity } from '@/entities';
const STORAGE_KEY = 'workflow_current_related_bot_value';
const TESTSET_CONNECTOR_ID = '10000';
interface SaveType {
id: string;
type: 'bot' | 'project';
}
interface CaseCachesType {
[FieldName.Bot]?: {
id: string;
type: IntelligenceType;
};
[key: string]: unknown;
}
@injectable()
export class RelatedCaseDataService {
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
caseCaches: CaseCachesType | undefined = undefined;
setCasePromise: Promise<void> | undefined = undefined;
private getURLSearchParamsBotId(): SaveType | undefined {
const sourceBotId = new URLSearchParams(window.location.search).get(
'bot_id',
);
if (!sourceBotId) {
return undefined;
}
return {
id: sourceBotId,
type: 'bot',
};
}
private async setDefaultCase(workflowJSON?: WorkflowJSON) {
const userInfo = userStoreService.getUserInfo();
const startNode = workflowJSON?.nodes?.find(
node => node.type === StandardNodeType.Start,
);
/* The community version does not currently support testset, for future expansion */
if (IS_OPEN_SOURCE) {
return;
}
const caseData = await debuggerApi.MGetCaseData({
bizCtx: {
connectorID: TESTSET_CONNECTOR_ID,
bizSpaceID: this.globalState.spaceId,
connectorUID: userInfo?.user_id_str,
},
bizComponentSubject: {
componentType: ComponentType.CozeStartNode,
parentComponentType: ComponentType.CozeWorkflow,
componentID: startNode?.id,
parentComponentID: this.globalState.workflowId,
},
caseName: undefined,
pageLimit: 1, // 默认测试集数据只会在第一条
});
const defaultCaseData = caseData?.cases?.find(
item => item?.caseBase?.isDefault,
);
if (defaultCaseData) {
this.caseCaches = getTestDataByTestset(defaultCaseData);
}
}
getDefaultCaseCaches() {
return this.caseCaches;
}
genSaveTypeFormCaches() {
let res: SaveType | undefined = undefined;
const caseData = this.getDefaultCaseCaches();
if (caseData?.[FieldName.Bot]?.id) {
res = {
id: caseData?.[FieldName.Bot]?.id,
type:
caseData[FieldName.Bot]?.type === IntelligenceType.Project
? 'project'
: 'bot',
};
}
return res;
}
async getAsyncRelatedBotValue(workflowJSON?: WorkflowJSON) {
const { workflowId } = this.globalState;
if (!workflowId) {
return undefined;
}
if (!this.setCasePromise) {
this.setCasePromise = this.setDefaultCase(workflowJSON);
}
const store = localStorage.getItem(STORAGE_KEY);
const jsonStore = typeSafeJSONParse(store);
let res: SaveType | undefined = undefined;
if (typeof jsonStore === 'object') {
res = jsonStore?.[`${workflowId}`];
}
if (!res) {
try {
await this.setCasePromise;
res = this.genSaveTypeFormCaches();
} catch (e) {
console.error('getDefaultCase Error: ', e);
}
}
const urlBot = this.getURLSearchParamsBotId();
if (urlBot) {
res = urlBot;
}
return res;
}
getRelatedBotValue() {
const { workflowId } = this.globalState;
if (!workflowId) {
return undefined;
}
const store = localStorage.getItem(STORAGE_KEY);
const jsonStore = typeSafeJSONParse(store);
let res: SaveType | undefined = undefined;
if (typeof jsonStore === 'object') {
res = jsonStore?.[`${workflowId}`];
}
if (!res) {
res = this.genSaveTypeFormCaches();
}
const urlBot = this.getURLSearchParamsBotId();
if (urlBot) {
res = urlBot;
}
return res;
}
updateRelatedBot(value?: SaveType, targetWorkflowId?: string) {
const workflowId = targetWorkflowId || this.globalState.workflowId;
if (!workflowId) {
return;
}
const store = localStorage.getItem(STORAGE_KEY);
let jsonStore = typeSafeJSONParse(store) as Record<string, SaveType>;
if (!jsonStore || typeof jsonStore !== 'object') {
jsonStore = {};
}
if (!value) {
delete jsonStore[`${workflowId}`];
localStorage.setItem(STORAGE_KEY, JSON.stringify(jsonStore));
} else {
jsonStore[`${workflowId}`] = value;
localStorage.setItem(STORAGE_KEY, JSON.stringify(jsonStore));
}
}
clear() {
localStorage.removeItem(STORAGE_KEY);
}
}

View File

@@ -0,0 +1,114 @@
/*
* 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 { createWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/shallow';
import { debounce } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { workflowApi } from '@coze-workflow/base';
import { type ChatFlowRole } from '@coze-arch/bot-api/workflow_api';
import { WorkflowGlobalStateEntity } from '@/entities';
const DEBOUNCE_TIME = 1000;
export interface RoleServiceState {
/**
* 是否首次加载完成
*/
isReady: boolean;
/**
* 是否在加载角色配置
*/
loading: boolean;
/**
* 是否在保存角色配置
*/
saving: boolean;
/**
* 角色配置数据
*/
data: ChatFlowRole | null;
}
const createStore = () =>
createWithEqualityFn<RoleServiceState>(
() => ({
isReady: false,
loading: false,
saving: false,
data: null,
}),
shallow,
);
@injectable()
export class RoleService {
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
store = createStore();
get role() {
return this.store.getState().data;
}
set role(v: ChatFlowRole | null) {
this.store.setState({
data: v,
});
}
set loading(v: boolean) {
this.store.setState({
loading: v,
});
}
async load() {
const { workflowId } = this.globalState;
this.loading = true;
const res = await workflowApi.GetChatFlowRole({
workflow_id: workflowId,
connector_id: '10000010',
ext: {
_caller: 'CANVAS',
},
});
this.store.setState({
isReady: true,
loading: false,
data: res.role || null,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async save(next: any) {
const { workflowId } = this.globalState;
await workflowApi.CreateChatFlowRole({
chat_flow_role: {
workflow_id: workflowId,
connector_id: '10000010',
...next,
},
});
this.role = next;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debounceSave = debounce((next: any) => {
this.save(next);
}, DEBOUNCE_TIME);
}

View File

@@ -0,0 +1,164 @@
/*
* 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 {
Tracker,
ReporterEventName,
type PickReporterParams,
type ReporterParams,
} from '@coze-workflow/test-run';
import Tea from '@coze-arch/tea';
import { WorkflowExeStatus } from '@coze-arch/bot-api/workflow_api';
import { WorkflowGlobalStateEntity } from '../entities';
const utils = {
executeStatus2TestRunResult: (executeStatus: WorkflowExeStatus) => {
const map = {
[WorkflowExeStatus.Success]: 'success',
[WorkflowExeStatus.Cancel]: 'cancel',
[WorkflowExeStatus.Fail]: 'fail',
};
return map[executeStatus] || 'success';
},
};
@injectable()
export class TestRunReporterService {
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
tracker = new Tracker();
utils = utils;
private send<T extends ReporterEventName>(key: T, params: ReporterParams[T]) {
if (!Tea || typeof Tea.event !== 'function') {
return;
}
Tea.event(key, params);
}
private workflowCommonParams() {
const params = {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
project_id: this.globalState.projectId,
workflow_mode: this.globalState.flowMode,
};
return params;
}
tryStart(params: PickReporterParams<ReporterEventName.TryStart, 'scene'>) {
const data: ReporterParams[ReporterEventName.TryStart] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.TryStart, data);
}
runEnd(
params: PickReporterParams<
ReporterEventName.RunEnd,
'testrun_type' | 'testrun_result' | 'execute_id'
>,
) {
const data: ReporterParams[ReporterEventName.RunEnd] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.RunEnd, data);
}
/** form schema 生成速度上报 */
formSchemaGen = {
start: () => this.tracker.start(),
end: (
key: string,
params: PickReporterParams<ReporterEventName.FormSchemaGen, 'node_type'>,
) => {
const time = this.tracker.end(key);
if (!time) {
return;
}
const data: ReporterParams[ReporterEventName.FormSchemaGen] = {
...this.workflowCommonParams(),
duration: time.duration,
...params,
};
this.send(ReporterEventName.FormSchemaGen, data);
},
};
formRunUIMode(
params: PickReporterParams<ReporterEventName.FormRunUIMode, 'form_ui_mode'>,
) {
const data: ReporterParams[ReporterEventName.FormRunUIMode] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.FormRunUIMode, data);
}
formGenDataOrigin(
params: PickReporterParams<
ReporterEventName.FormGenDataOrigin,
'gen_data_origin'
>,
) {
const data: ReporterParams[ReporterEventName.FormGenDataOrigin] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.FormGenDataOrigin, data);
}
logRawOutputDifference(
params: PickReporterParams<
ReporterEventName.LogOutputDifference,
'error_msg' | 'log_node_type' | 'is_difference'
>,
) {
const data: ReporterParams[ReporterEventName.LogOutputDifference] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.LogOutputDifference, data);
}
logOutputMarkdown(
params: PickReporterParams<
ReporterEventName.LogOutputMarkdown,
'action_type'
>,
) {
const data: ReporterParams[ReporterEventName.LogOutputMarkdown] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.LogOutputMarkdown, data);
}
traceOpen(
params: PickReporterParams<
ReporterEventName.TraceOpen,
'log_id' | 'panel_type'
>,
) {
const data: ReporterParams[ReporterEventName.TraceOpen] = {
...this.workflowCommonParams(),
...params,
};
this.send(ReporterEventName.TraceOpen, data);
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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 { pick } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { QueryClient } from '@tanstack/react-query';
import {
StandardNodeType,
workflowApi,
type WorkflowDetailInfoData,
} from '@coze-workflow/base';
import {
type TriggerFormMeta,
fetchStartNodeTriggerFormValue,
fetchTriggerFormMeta,
} from '@/node-registries/trigger-upsert/utils';
import { WorkflowGlobalStateEntity } from '@/entities';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
export const loadSubWorkflowInfo = async ({
spaceId,
workflowId,
}: {
spaceId: string;
workflowId: string;
isInProject: boolean;
}) => {
const data = await queryClient.fetchQuery({
queryKey: ['loadSubWorkflowInfo', spaceId, workflowId],
queryFn: async () => {
const resp = await workflowApi.GetWorkflowDetailInfo(
{
space_id: spaceId,
workflow_filter_list: [
{
workflow_id: workflowId,
},
],
},
{ __disableErrorToast: true },
);
const workflowInfo = resp?.data?.[0];
return workflowInfo;
},
});
return data;
};
@injectable()
export class TriggerService {
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
protected formMeta: TriggerFormMeta;
protected subWorkflowInfos: Record<string, WorkflowDetailInfoData> = {};
protected startNodeFormValues: Record<string, unknown> = {};
async load() {
// The community version does not support the project trigger feature, for future expansion
if (this.globalState.projectId && !IS_OPEN_SOURCE) {
const meta = await fetchTriggerFormMeta({
spaceId: this.globalState.spaceId,
projectId: this.globalState.projectId,
workflowId: this.globalState.workflowId,
});
this.formMeta = meta;
const schema = JSON.parse(this.globalState.info?.schema_json || '{}');
const startNode = schema.nodes.filter(
node => node.type === StandardNodeType.Start,
);
const { formValue, triggerId } = await fetchStartNodeTriggerFormValue({
spaceId: this.globalState.spaceId,
projectId: this.globalState.projectId,
workflowId: this.globalState.workflowId,
projectVersion: this.globalState.projectCommitVersion,
outputs: startNode?.[0]?.data?.outputs,
});
// const { dynamicInputs, ...rest } = formValue ?? {};
const dynamicInputs = pick(
formValue,
meta.startNodeFormMeta.map(d => d.name),
);
this.setStartNodeFormValues({
isOpen: formValue.isOpen,
dynamicInputs: dynamicInputs ?? meta.startNodeDefaultFormValue,
parameters: formValue.parameters,
triggerId,
});
const triggerUpsertNodes = schema.nodes.filter(
node => node.type === StandardNodeType.TriggerUpsert,
);
const bindWorkflowIds = triggerUpsertNodes
.map(node => node?.data?.inputs?.meta?.workflowId)
.filter(Boolean);
await Promise.all(
bindWorkflowIds.map(workflowId => this.setBindWorkflowInfo(workflowId)),
);
}
}
getTriggerDynamicFormMeta() {
return this.formMeta;
}
getBindWorkflowInfo = (workflowId?: string) => {
if (!workflowId) {
return undefined;
}
return this.subWorkflowInfos[workflowId];
};
setBindWorkflowInfo = async (workflowId?: string) => {
if (!workflowId || this.subWorkflowInfos[workflowId]) {
return;
}
const info = await loadSubWorkflowInfo({
spaceId: this.globalState.spaceId,
isInProject: !!this.globalState.projectId,
workflowId,
});
this.subWorkflowInfos[workflowId] = info;
};
getStartNodeFormValues = () => this.startNodeFormValues;
setStartNodeFormValues = (values: Record<string, unknown>) => {
this.startNodeFormValues = {
...this.startNodeFormValues,
...values,
};
};
}

View File

@@ -0,0 +1,114 @@
/*
* 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 @typescript-eslint/no-explicit-any */
import { injectable, inject } from 'inversify';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
type ValueExpression,
type ValueExpressionDTO,
type RefExpression,
ValueExpressionType,
WorkflowVariableService,
variableUtils,
} from '@coze-workflow/variable';
import { ValueExpression as ValueExpressionUtils } from '@coze-workflow/base';
import { type ValueExpressionService } from './value-expression-service';
@injectable()
export class ValueExpressionServiceImpl implements ValueExpressionService {
@inject(WorkflowVariableService)
private readonly variableService: WorkflowVariableService;
public isValueExpression(value: unknown): boolean {
if (value === undefined || value === null) {
return false;
}
return ValueExpressionUtils.isExpression(value as ValueExpression);
}
public isValueExpressionDTO(value: unknown): boolean {
return (
(typeof value === 'object' &&
value !== null &&
(value as any).value?.type === ValueExpressionType.REF) ||
(value as any).value?.type === ValueExpressionType.LITERAL
);
}
public isRefExpression(value: unknown): boolean {
if (value === undefined || value === null) {
return false;
}
return ValueExpressionUtils.isRef(value as ValueExpression);
}
public isLiteralExpression(value: unknown): boolean {
if (value === undefined || value === null) {
return false;
}
return ValueExpressionUtils.isLiteral(value as ValueExpression);
}
// 将 ValueExpression 转换为 ValueExpressionDTO
public toDTO(
valueExpression?: ValueExpression,
currentNode?: FlowNodeEntity,
): ValueExpressionDTO | undefined {
if (!valueExpression) {
return undefined;
}
const dto = variableUtils.valueExpressionToDTO(
valueExpression,
this.variableService,
{ node: currentNode },
);
return dto;
}
// 从 ValueExpressionDTO 生成 ValueExpression
public toVO(dto?: ValueExpressionDTO): ValueExpression | undefined {
if (!dto) {
return undefined;
}
const vo = variableUtils.valueExpressionToVO(dto, this.variableService);
return vo;
}
public isRefExpressionVariableExists(
value: RefExpression,
node: FlowNodeEntity,
): boolean {
const variable = this.variableService.getViewVariableByKeyPath(
value?.content?.keyPath,
{
node,
},
);
const isValidVariable = variable !== null && variable !== undefined;
return isValidVariable;
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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';
import {
type ValueExpression,
type ValueExpressionDTO,
type RefExpression,
} from '@coze-workflow/variable';
export abstract class ValueExpressionService {
/**
* 判断值是否为值表达式
* @param value 值
* @returns 是否为值表达式
*/
abstract isValueExpression(value: unknown): boolean;
/**
* 判断值是否为值表达式DTO
* @param value 值
* @returns 是否为值表达式DTO
*/
abstract isValueExpressionDTO(value: unknown): boolean;
/**
* 判断值是否为引用表达式
* @param value 值
* @returns 是否为引用表达式
*/
abstract isRefExpression(value: unknown): boolean;
/**
* 判断值是否为字面量表达式
* @param value 值
* @returns 是否为字面量表达式
*/
abstract isLiteralExpression(value: RefExpression): boolean;
/**
* 判断引用表达式变量是否存在
* @param value 引用表达式
* @param node 当前节点
* @returns 是否存在
*/
abstract isRefExpressionVariableExists(
value: RefExpression,
node: FlowNodeEntity,
): boolean;
/**
* 将值表达式转换为DTO
* @param valueExpression 值表达式
* @param currentNode 当前节点
* @returns 值表达式DTO
*/
abstract toDTO(
valueExpression?: ValueExpression,
currentNode?: FlowNodeEntity,
): ValueExpressionDTO | undefined;
/**
* 将值表达式DTO转换为值表达式
* @param dto 值表达式DTO
* @returns 值表达式
*/
abstract toVO(dto?: ValueExpressionDTO): ValueExpression | undefined;
}

View File

@@ -0,0 +1,250 @@
/*
* 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 { get, debounce } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeFormData } from '@flowgram-adapter/free-layout-editor';
import { WorkflowDocument } from '@flowgram-adapter/free-layout-editor';
import { Emitter, type Event } from '@flowgram-adapter/common';
import {
MessageBizType,
MessageOperateType,
StandardNodeType,
} from '@coze-workflow/base';
import type { WsMessageProps } from '@coze-project-ide/framework/src/types';
import { getFlags } from '@coze-arch/bot-flags';
import {
WorkflowGlobalStateEntity,
WorkflowDependencyStateEntity,
} from '@/entities';
import { DependencySourceType } from '@/constants';
import { WorkflowSaveService } from './workflow-save-service';
import { TestRunState, WorkflowRunService } from './workflow-run-service';
import { WorkflowOperationService } from './workflow-operation-service';
import { WorkflowModelsService } from './workflow-models-service';
export const bizTypeToDependencyTypeMap = {
[MessageBizType.Workflow]: DependencySourceType.Workflow,
[MessageBizType.Plugin]: DependencySourceType.Plugin,
[MessageBizType.Dataset]: DependencySourceType.DataSet,
[MessageBizType.Database]: DependencySourceType.DataBase,
};
const DEBOUNCE_TIME = 2000;
export interface DependencyStore {
refreshModalVisible: boolean;
setRefreshModalVisible: (visible: boolean) => void;
}
interface SubworkflowVersionChangeProps {
subWorkflowId: string;
}
@injectable()
export class WorkflowDependencyService {
@inject(WorkflowModelsService) modelsService: WorkflowModelsService;
@inject(WorkflowDocument) protected workflowDocument: WorkflowDocument;
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
@inject(WorkflowSaveService) saveService: WorkflowSaveService;
@inject(WorkflowOperationService) operationService: WorkflowOperationService;
@inject(WorkflowRunService) testRunService: WorkflowRunService;
@inject(WorkflowDependencyStateEntity)
dependencyEntity: WorkflowDependencyStateEntity;
onDependencyChangeEmitter = new Emitter<WsMessageProps | undefined>();
onDependencyChange: Event<WsMessageProps | undefined> =
this.onDependencyChangeEmitter.event;
onSubWrokflowVersionChangeEmitter = new Emitter<
SubworkflowVersionChangeProps | undefined
>();
onSubWrokflowVersionChange: Event<SubworkflowVersionChangeProps | undefined> =
this.onSubWrokflowVersionChangeEmitter.event;
/**
* 可能的 badcase:
* - save 接口返回较慢,导致长链刷新先于版本冲突报错的刷新弹窗,但是该场景比较极限,后续有必要再优化
* - canvas 接口返回较慢,导致长链消息推送了一条和当前版本一致的消息,导致一次额外刷新。
* 通过刷新前再判断一次版本号,避免该问题。
*/
private workflowDocumentReload = debounce(
(callback, messageVersion?: bigint) => {
const isVersionNewer =
messageVersion && this.dependencyEntity.saveVersion > messageVersion;
if (this.dependencyEntity.refreshModalVisible || isVersionNewer) {
return;
}
callback?.();
},
DEBOUNCE_TIME,
);
updateDependencySources(props: WsMessageProps, callback?: () => void) {
const FLAGS = getFlags();
if (!FLAGS['bot.automation.project_multi_tab']) {
return;
}
const allNodes = this.workflowDocument.getAllNodes();
const llmNodes = allNodes.filter(
n => n.flowNodeType === StandardNodeType.LLM,
);
// LLM 节点技能更新
llmNodes?.forEach(node => {
const formData = node.getData(FlowNodeFormData);
const formValue = formData.toJSON();
const llmSkillIdFields = [
{ field: 'fcParam.pluginFCParam.pluginList', key: 'api_id' },
{ field: 'fcParam.knowledgeFCParam.knowledgeList', key: 'id' },
{ field: 'fcParam.workflowFCParam.workflowList', key: 'workflow_id' },
];
const skillNeedRefresh = llmSkillIdFields.some(subSkill => {
const subSkillList = get(formValue.inputs, subSkill.field) ?? [];
return subSkillList.find(
(item: unknown) => get(item, subSkill.key) === props?.resId,
);
});
if (skillNeedRefresh) {
const nextProps: WsMessageProps = {
...props,
extra: {
nodeIds: [node.id],
},
};
this.onDependencyChangeEmitter.fire(nextProps);
}
});
const { saveVersion } = this.dependencyEntity;
const dependencyHandlers: {
[key in DependencySourceType]: (
nodes: FlowNodeEntity[],
source?: WsMessageProps,
) => Promise<void> | void;
} = {
[DependencySourceType.Workflow]: (_nodes, resource) => {
// 当前工作流在其他页面被更新
if (resource?.resId === this.globalState.workflowId) {
// 修改工作流名称或描述的情况 saveVersion 不会更新
const isMetaUpdate =
resource?.operateType === MessageOperateType.MetaUpdate;
// 切换工作流类型场景需要刷新
const isFlowModeChange =
resource?.extra?.flowMode !== undefined &&
this.globalState.flowMode.toString() !== resource.extra.flowMode;
const metaUpdateNeedRefresh = isMetaUpdate && isFlowModeChange;
// 试运行过程中不需要刷新
const isTestRunning =
this.testRunService.testRunState === TestRunState.Executing ||
this.testRunService.testRunState === TestRunState.Paused;
// 当前版本大于其他页面保存版本,不需要刷新
const resourceVersion = BigInt(resource?.saveVersion ?? 0);
const isCurVersionNewer = saveVersion > resourceVersion;
if (
isCurVersionNewer ||
this.dependencyEntity.refreshModalVisible ||
isTestRunning
) {
if (metaUpdateNeedRefresh) {
this.workflowDocumentReload(callback);
}
return;
}
this.workflowDocumentReload(callback, resourceVersion);
return;
}
const subWorkflowNodes = _nodes.filter(
n => n.flowNodeType === StandardNodeType.SubWorkflow,
);
const needUpdateNodeIds: string[] = [];
subWorkflowNodes?.forEach(node => {
const formData = node.getData(FlowNodeFormData);
const formValue = formData.toJSON();
const { workflowId } = formValue.inputs;
// 当前工作流内子工作流被更新
if (resource?.resId === workflowId) {
needUpdateNodeIds.push(workflowId);
}
});
if (!needUpdateNodeIds.length) {
return;
}
const nextProps: WsMessageProps = {
...props,
extra: {
nodeIds: needUpdateNodeIds,
},
};
this.onDependencyChangeEmitter.fire(nextProps);
},
[DependencySourceType.Plugin]: (nodes, resource) => {
const apiNodes = nodes.filter(
n => n.flowNodeType === StandardNodeType.Api,
);
const needUpdateNodeIds: string[] = [];
apiNodes?.forEach(node => {
const formData = node.getData(FlowNodeFormData);
const formValue = formData.toJSON();
const apiParam = formValue.inputs.apiParam.find(
param => param.name === 'apiID',
);
const apiID = apiParam.input.value.content ?? '';
if (apiID === resource?.resId) {
needUpdateNodeIds.push(node.id);
}
});
if (!needUpdateNodeIds.length) {
return;
}
const nextProps: WsMessageProps = {
...props,
extra: {
nodeIds: needUpdateNodeIds,
},
};
this.onDependencyChangeEmitter.fire(nextProps);
},
[DependencySourceType.DataSet]: (_, resource) => {
// 清空知识库缓存
this.globalState.sharedDataSetStore.clearDataSetInfosMap();
this.onDependencyChangeEmitter.fire(resource);
},
[DependencySourceType.DataBase]: (_, resource) => {
this.onDependencyChangeEmitter.fire(resource);
},
[DependencySourceType.LLM]: (_, resource) => {
this.onDependencyChangeEmitter.fire(resource);
},
};
if (props?.bizType) {
// 设置刷新弹窗中的刷新方法
this.dependencyEntity.setRefreshFunc(() => {
callback?.();
});
const dependencyType = bizTypeToDependencyTypeMap[props.bizType];
dependencyHandlers[dependencyType](allNodes, props);
}
}
}

View File

@@ -0,0 +1,295 @@
/*
* 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 XYCoord } from 'react-dnd';
import { inject, injectable } from 'inversify';
import {
FlowNodeBaseType,
FlowNodeTransformData,
} from '@flowgram-adapter/free-layout-editor';
import { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowSelectService,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
type WorkflowNodeMeta,
} from '@flowgram-adapter/free-layout-editor';
import {
Emitter,
Rectangle,
type PositionSchema,
} from '@flowgram-adapter/common';
import { StandardNodeType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
export interface CardDragEvent {
type: 'startDrag' | 'endDrag';
nodeType?: StandardNodeType;
json?: Partial<WorkflowNodeJSON>;
}
interface WorkflowCustomDragServiceState {
isDragging: boolean;
dragNode?: {
type: StandardNodeType;
json?: Partial<WorkflowNodeJSON>;
};
transforms?: FlowNodeTransformData[];
dropNode?: WorkflowNodeEntity;
}
@injectable()
export class WorkflowCustomDragService extends WorkflowDragService {
public state: WorkflowCustomDragServiceState;
private cardDragEmitter = new Emitter<CardDragEvent>();
readonly onCardDrag = this.cardDragEmitter.event;
constructor(
@inject(WorkflowDocument) protected document: WorkflowDocument,
@inject(WorkflowSelectService)
protected selectService: WorkflowSelectService,
@inject(PlaygroundConfigEntity)
protected playgroundConfig: PlaygroundConfigEntity,
) {
super();
this.initState();
this._toDispose.pushAll([this.cardDragEmitter]);
}
/** 开始拖拽 */
public startDrag(dragNode: WorkflowCustomDragServiceState['dragNode']): void {
const { isDragging, dragNode: oldDragNode } = this.state;
if (isDragging && oldDragNode) {
return;
}
this.isDragging = true;
this.state.isDragging = true;
this.state.dragNode = dragNode;
const containerTransforms = this.document
.getRenderDatas(FlowNodeTransformData, false)
.filter(transform => {
const { entity } = transform;
if (entity.originParent) {
return (
entity.getNodeMeta().selectable &&
entity.originParent.getNodeMeta().selectable
);
}
return entity.getNodeMeta().selectable;
})
.filter(transform => {
const { entity } = transform;
return entity.getNodeMeta<WorkflowNodeMeta>().isContainer;
});
this.state.transforms = containerTransforms;
this.cardDragEmitter.fire({
type: 'startDrag',
nodeType: dragNode?.type,
json: dragNode?.json,
});
}
/** 结束拖拽 */
public endDrag() {
const { isDragging, dragNode } = this.state;
if (!isDragging && !dragNode?.type) {
return;
}
this.isDragging = false;
this.state.isDragging = false;
this.state.dragNode = undefined;
this.state.transforms = undefined;
this.cardDragEmitter.fire({
type: 'endDrag',
nodeType: dragNode?.type,
json: dragNode?.json,
});
}
/** 根据坐标判断是否可放置 */
public canDrop(params: {
coord: XYCoord;
dragNode: WorkflowCustomDragServiceState['dragNode'];
}): boolean {
const { dragNode } = params;
if (!dragNode?.type) {
return false;
}
const { allowDrop, dropNode } = this.computeCanDrop(params);
this.setDropNode(dropNode);
return allowDrop;
}
/**
* 是否可放置到节点
* NOTICE: 以下逻辑后续如果还有特化需要考虑放到节点meta配置中
*/
public canDropToNode(params: {
dragNodeType?: StandardNodeType;
dropNode?: WorkflowNodeEntity;
}): {
allowDrop: boolean;
message?: string;
dropNode?: WorkflowNodeEntity;
} {
const { dragNodeType } = params;
const dropNode: WorkflowNodeEntity = params.dropNode ?? this.document.root;
if (!dragNodeType) {
return {
allowDrop: false,
};
}
// 开始 / 结束节点不允许放入任何容器
if ([StandardNodeType.Start, StandardNodeType.End].includes(dragNodeType)) {
return {
allowDrop: false,
dropNode,
};
}
// Loop / Batch 节点不允许嵌套
if (
[StandardNodeType.Loop, StandardNodeType.Batch].includes(dragNodeType) &&
dropNode?.getNodeMeta<WorkflowNodeMeta>().isContainer
) {
return {
allowDrop: false,
message: I18n.t('workflow_loop_nest_tips'),
dropNode,
};
}
// Break节点与SetVariable节点仅能拖入Loop节点
if (
[
StandardNodeType.Break,
StandardNodeType.Continue,
StandardNodeType.SetVariable,
].includes(dragNodeType)
) {
const dropNodeMeta = dropNode.getNodeMeta<WorkflowNodeMeta>();
const dropSubCanvas = dropNodeMeta.subCanvas?.(dropNode);
if (
dropSubCanvas?.isCanvas &&
dropSubCanvas.parentNode.flowNodeType === StandardNodeType.Loop
) {
return {
allowDrop: true,
message: I18n.t('workflow_loop_release_tips'),
dropNode,
};
} else {
return {
allowDrop: false,
message: I18n.t('workflow_loop_onlycanva_tips'),
dropNode,
};
}
}
// 放置节点为容器
if (
[FlowNodeBaseType.ROOT, FlowNodeBaseType.SUB_CANVAS].includes(
dropNode.flowNodeType as FlowNodeBaseType,
) ||
dropNode.getNodeMeta<WorkflowNodeMeta>().isContainer
) {
return {
allowDrop: true,
dropNode,
};
}
return {
allowDrop: false,
};
}
/** 是否可放置 */
public computeCanDrop(params: {
coord: XYCoord;
dragNode: WorkflowCustomDragServiceState['dragNode'];
}): {
allowDrop: boolean;
message?: string;
dropNode?: WorkflowNodeEntity;
} {
const { coord, dragNode } = params;
const addNodePanelWidth = 200;
const position = this.playgroundConfig.getPosFromMouseEvent(
{
clientX: coord.x + addNodePanelWidth,
clientY: coord.y,
},
true,
);
if (!dragNode?.type) {
return {
allowDrop: false,
};
}
const collisionTransform = this.getCollisionTransform({
position,
dragNode,
});
const dropNode = collisionTransform?.entity;
return this.canDropToNode({
dragNodeType: dragNode.type,
dropNode,
});
}
/** 初始化状态 */
private initState(): void {
this.state = {
isDragging: false,
dragNode: undefined,
transforms: undefined,
dropNode: undefined,
};
}
/** 获取重叠位置 */
private getCollisionTransform(params: {
position: PositionSchema;
dragNode: WorkflowCustomDragServiceState['dragNode'];
}): FlowNodeTransformData | undefined {
const { transforms } = this.state;
const { position, dragNode } = params;
if (!dragNode?.type || !transforms || transforms.length === 0) {
return undefined;
}
const draggingRect = new Rectangle(position.x, position.y, 200, 30);
const collisionTransform = transforms.find(transform => {
const { bounds, entity } = transform;
const padding = this.document.layout.getPadding(entity);
const transformRect = new Rectangle(
bounds.x + padding.left + padding.right,
bounds.y,
bounds.width,
bounds.height,
);
// 检测两个正方形是否相互碰撞
return Rectangle.intersects(draggingRect, transformRect);
});
return collisionTransform;
}
/** 设置当前放置节点 */
private setDropNode(newDropNode?: WorkflowNodeEntity) {
this.state.dropNode = newDropNode;
if (newDropNode) {
this.selectService.select(newDropNode);
} else {
this.selectService.clear();
}
}
}

View File

@@ -0,0 +1,302 @@
/*
* 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 @typescript-eslint/no-non-null-assertion */
import { set } from 'lodash-es';
import { inject, injectable } from 'inversify';
import {
FlowNodeFormData,
type FormModelV2,
} from '@flowgram-adapter/free-layout-editor';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowNodeMeta,
WorkflowSelectService,
getAntiOverlapPosition,
type WorkflowNodeEntity,
type WorkflowNodeJSON,
type WorkflowNodeRegistry,
type WorkflowSubCanvas,
} from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
import { WorkflowNodesService } from '@coze-workflow/nodes';
import { StandardNodeType, reporter } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Modal } from '@coze-arch/bot-semi';
import { handlePluginRiskWarning } from '@coze-agent-ide/plugin-risk-warning';
import { WorkflowPlaygroundContext } from '@/workflow-playground-context';
import { getNodeV2Registry, isNodeV2 } from '@/nodes-v2';
import { LayoutPanelKey } from '@/constants';
import { WorkflowGlobalStateEntity } from '../entities';
import { WorkflowFloatLayoutService } from './workflow-float-layout-service';
import { WorkflowCustomDragService } from './workflow-drag-service';
/**
* 调用画布编辑服务
*/
@injectable()
export class WorkflowEditService {
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
@inject(WorkflowCustomDragService)
readonly dragService: WorkflowCustomDragService;
@inject(WorkflowGlobalStateEntity) workflowState: WorkflowGlobalStateEntity;
@inject(WorkflowDocument) protected workflowDocument: WorkflowDocument;
@inject(WorkflowSelectService) protected selectService: WorkflowSelectService;
@inject(WorkflowNodesService) protected nodesService: WorkflowNodesService;
@inject(PlaygroundConfigEntity)
protected playgroundConfig: PlaygroundConfigEntity;
@inject(WorkflowFloatLayoutService)
protected floatLayoutService: WorkflowFloatLayoutService;
@inject(WorkflowPlaygroundContext)
protected context: WorkflowPlaygroundContext;
/**
* 创建节点
* @param type
* @param nodeJson
* @param event
*/
// eslint-disable-next-line max-params
addNode = async (
type: StandardNodeType,
nodeJson?: Partial<WorkflowNodeJSON>,
event?: { clientX: number; clientY: number },
isDrag?: boolean,
): Promise<WorkflowNodeEntity | undefined> => {
if (this.globalState.readonly) {
return;
}
if (type === StandardNodeType.Api) {
handlePluginRiskWarning();
}
let dragNode: WorkflowNodeEntity | undefined;
// create uniq title
if (nodeJson && nodeJson.data.nodeMeta.title) {
nodeJson.data.nodeMeta.title = this.nodesService.createUniqTitle(
nodeJson.data.nodeMeta.title,
);
}
if (!event) {
// 异常处理
this.dragService.endDrag();
return;
}
try {
// 节点初始化逻辑
const nodeV2Registry = getNodeV2Registry(type);
await nodeV2Registry?.onInit?.(
nodeJson as WorkflowNodeJSON,
this.context,
);
} catch (error) {
reporter.errorEvent({
eventName: 'workflow_registry_v2_on_init_error',
namespace: 'workflow',
error,
});
}
if (isDrag) {
// 拖拽生成节点
dragNode = await this.dragService.dropCard(
type,
event,
nodeJson,
this.dragService.state.dropNode,
);
} else {
// @deprecated 这里的逻辑目前跑不到这里来,后续可以考虑删掉
let position: IPoint;
const nodeMeta =
this.workflowDocument.getNodeRegister<WorkflowNodeRegistry>(type).meta;
const { width } = nodeMeta?.size || { width: 0, height: 0 };
position = this.playgroundConfig.getPosFromMouseEvent(event);
position = getAntiOverlapPosition(this.workflowDocument, {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
x: position.x + width / 2,
y: position.y,
});
const dropNode = this.getDropNode();
const { allowDrop } = this.dragService.canDropToNode({
dragNodeType: type,
dropNode,
});
if (!allowDrop) {
return;
}
if (dropNode && dropNode.flowNodeType !== FlowNodeBaseType.ROOT) {
const childrenLength = dropNode.collapsedChildren.length;
const dropNodePadding =
this.workflowDocument.layout.getPadding(dropNode);
position = {
x: dropNodePadding.left + childrenLength * 30,
y: dropNodePadding.top + childrenLength * 30,
};
}
dragNode = await this.workflowDocument.createWorkflowNodeByType(
type,
position as IPoint,
nodeJson,
dropNode.id,
);
}
this.dragService.endDrag();
if (dragNode) {
this.focusNode(dragNode);
}
return dragNode;
};
private getDropNode(): WorkflowNodeEntity {
const { activatedNode } = this.selectService;
if (!activatedNode) {
return this.workflowDocument.root;
}
const linageNodes: WorkflowNodeEntity[] = [];
let currentNode: WorkflowNodeEntity | undefined = activatedNode;
while (currentNode) {
linageNodes.push(currentNode);
currentNode = currentNode.parent;
}
return (
linageNodes.find(
n =>
[FlowNodeBaseType.ROOT, FlowNodeBaseType.SUB_CANVAS].includes(
n.flowNodeType as FlowNodeBaseType,
) || n.getNodeMeta<WorkflowNodeMeta>().isContainer,
) ?? this.workflowDocument.root
);
}
/**
* 复制节点
* @param node
*/
copyNode = async (node: WorkflowNodeEntity): Promise<WorkflowNodeEntity> => {
const json = await this.workflowDocument.toNodeJSON(node);
const position = {
x: json.meta!.position!.x + 30,
y: json.meta!.position!.y + 30,
};
const newNode = await this.workflowDocument.copyNodeFromJSON(
json.type as string,
this.recreateNodeJSON(json),
'',
position,
node.parent?.id,
);
const subCanvas: WorkflowSubCanvas = newNode
.getNodeMeta()
?.subCanvas?.(newNode);
if (subCanvas?.canvasNode) {
this.selectService.selection = [newNode, subCanvas.canvasNode];
} else {
this.focusNode(newNode);
}
return newNode;
};
/**
* 删除线条
*/
deleteNode = (node: WorkflowNodeEntity, noConfirm?: boolean) => {
if (noConfirm) {
this.disposeNode(node);
} else {
Modal.error({
// closable: false,
title: I18n.t('workflow_detail_select_delete_popup_title'),
content: I18n.t('workflow_detail_select_delete_popup_subtitle'),
onOk: () => this.disposeNode(node),
okText: I18n.t('workflow_add_delete'),
cancelText: I18n.t('Cancel'),
});
}
};
/**
* 在销毁节点之前,运行节点自定义的 onDispose 方法
* @param node
*/
private disposeNode(node: WorkflowNodeEntity) {
if (isNodeV2(node)) {
const formModel = node
.getData<FlowNodeFormData>(FlowNodeFormData)
.getFormModel<FormModelV2>();
node.getNodeRegister()?.onDispose?.(formModel.getValues(), this.context);
}
node.dispose();
}
recreateNodeJSON(
nodeJSON: WorkflowNodeJSON,
titleCache: string[] = [],
shouldReplaceId = true,
): WorkflowNodeJSON {
// 覆写 ID
if (shouldReplaceId) {
nodeJSON.id = this.nodesService.createUniqID();
}
// 覆写标题
if (nodeJSON.data?.nodeMeta?.title) {
set(
nodeJSON,
'data.nodeMeta.title',
this.nodesService.createUniqTitle(
nodeJSON.data.nodeMeta.title,
undefined,
titleCache,
),
);
titleCache.push(nodeJSON.data.nodeMeta.title);
}
// 对子节点做递归处理
if (nodeJSON.blocks) {
nodeJSON.blocks = nodeJSON.blocks.map(n =>
this.recreateNodeJSON(n, titleCache),
);
}
return nodeJSON;
}
/** 选中节点,如果右侧浮动面板是节点表单,则切换节点 */
focusNode(node?: WorkflowNodeEntity): void {
if (node) {
this.selectService.selectNodeAndFocus(node);
}
if (
this.floatLayoutService.getPanel('right').key !== LayoutPanelKey.NodeForm
) {
return;
}
if (!node) {
this.floatLayoutService.close('right');
return;
}
this.floatLayoutService.open(LayoutPanelKey.NodeForm, 'right', {
node,
});
}
}

View File

@@ -0,0 +1,138 @@
/*
* 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 React from 'react';
import { isFunction, isPlainObject, isString } from 'lodash-es';
import { injectable } from 'inversify';
import { Emitter } from '@flowgram-adapter/common';
type Area = 'right' | 'bottom';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Render = (props?: any) => React.ReactNode;
export interface PanelInfo {
key: string;
area: Area;
}
export interface LayoutSize {
height: number;
width: number;
}
export class FloatLayoutPanel {
key: string | null = null;
panel: React.ReactNode = null;
onUpdateEmitter = new Emitter<React.ReactNode>();
onUpdate = this.onUpdateEmitter.event;
update(val: React.ReactNode) {
this.onUpdateEmitter.fire(val);
}
close() {
this.panel = null;
this.key = null;
this.update(null);
}
open(node: React.ReactNode, key: string) {
this.panel = node;
this.key = key;
this.update(node);
}
render() {
return this.panel;
}
}
@injectable()
export class WorkflowFloatLayoutService {
/** layout size */
size = {
height: 0,
width: 0,
};
onSizeChangeEmitter = new Emitter<LayoutSize>();
onSizeChange = this.onSizeChangeEmitter.event;
right = new FloatLayoutPanel();
bottom = new FloatLayoutPanel();
private components = new Map<string, Render>();
onMountEmitter = new Emitter<PanelInfo>();
onMount = this.onMountEmitter.event;
onUnmountEmitter = new Emitter<PanelInfo>();
onUnmount = this.onUnmountEmitter.event;
register(val: Record<string, Render>): void;
register(val: string, component: Render): void;
register(
val: string | Record<string, () => React.ReactNode>,
component?: () => React.ReactNode,
) {
if (isPlainObject(val)) {
Object.entries(val).forEach(([key, comp]) => {
this.register(key, comp);
});
} else if (isString(val) && component && isFunction(component)) {
this.components.set(val, component);
}
}
open<T>(key: string, area: Area = 'right', props?: T) {
const panel = this.getPanel(area);
const component = this.components.get(key);
if (component && isFunction(component)) {
const prevKey = panel.key;
panel.open(component(props), key);
if (prevKey) {
this.onUnmountEmitter.fire({ key: prevKey, area });
}
this.onMountEmitter.fire({ key, area });
}
}
close(area: Area = 'right') {
const panel = this.getPanel(area);
const prevKey = panel.key;
panel.close();
if (prevKey) {
this.onUnmountEmitter.fire({ key: prevKey, area });
}
}
closeAll() {
this.close('right');
this.close('bottom');
}
getPanel(area: Area) {
if (area === 'right') {
return this.right;
}
return this.bottom;
}
setLayoutSize(size: LayoutSize) {
this.size = size;
this.onSizeChangeEmitter.fire(size);
}
}

View File

@@ -0,0 +1,210 @@
/*
* 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 { intersection } from 'lodash-es';
import { inject, injectable, postConstruct } from 'inversify';
import { WorkflowNodePanelService } from '@flowgram-adapter/free-layout-editor';
import { type PlaygroundDragEvent } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowLinesManager,
type WorkflowLinePortInfo,
type WorkflowLineEntity,
type WorkflowPortEntity,
} from '@flowgram-adapter/free-layout-editor';
import {
type PositionSchema,
type Disposable,
DisposableCollection,
} from '@flowgram-adapter/common';
import { ValidationService } from '@coze-workflow/base/services';
import type { StandardNodeType } from '@coze-workflow/base';
import { Toast } from '@coze-arch/coze-design';
import { WorkflowGlobalStateEntity } from '../entities';
import { WorkflowCustomDragService } from './workflow-drag-service';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const WorkflowLinesServiceProvider = Symbol(
'WorkflowLinesServiceProvider',
);
@injectable()
export class WorkflowLinesService {
@inject(ValidationService) private validationService: ValidationService;
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
@inject(WorkflowLinesManager)
protected readonly linesManager: WorkflowLinesManager;
@inject(WorkflowNodePanelService)
protected nodePanelService: WorkflowNodePanelService;
@inject(WorkflowCustomDragService)
protected dragService: WorkflowCustomDragService;
protected toDispose = new DisposableCollection();
@postConstruct()
init() {
this.toDispose.pushAll([this.onDragLineEnd()]);
}
dispose() {
this.toDispose.dispose();
}
getLine(fromId?: string, toId?: string): WorkflowLineEntity | undefined {
const allLines = this.linesManager.getAllLines();
return allLines.find(
line => line.from.id === fromId && line.to?.id === toId,
);
}
getAllLines() {
return this.linesManager.getAllLines();
}
createLine(options: WorkflowLinePortInfo) {
return this.linesManager.createLine(options);
}
isError(fromId?: string, toId?: string): boolean {
if (!fromId) {
// 不允许无输入端口
return true;
}
const line = this.getLine(fromId, toId);
if (!line) {
// 输出不存在
return false;
}
return this.validationService.isLineError(fromId, toId);
}
validateLine(fromId: string, toId: string) {
const line = this.getLine(fromId, toId);
line?.validate();
}
validateAllLine() {
const allLines = this.linesManager.getAllLines();
allLines.forEach(line => line.validate());
}
// 将两个端口的连线进行交换
replaceLineByPort(
oldPortInfo: WorkflowLinePortInfo,
newPortInfo: WorkflowLinePortInfo,
) {
const allLines = this.linesManager.getAllLines();
const oldPortLines = allLines.filter(
line =>
line.info.from === oldPortInfo.from &&
line.info.fromPort === oldPortInfo.fromPort,
);
const newPortLines = allLines.filter(
line =>
line.info.from === newPortInfo.from &&
line.info.fromPort === newPortInfo.fromPort,
);
// 记录所有连线终点交集
const intersectionToPort = intersection(
oldPortLines.map(line => line.toPort?.id),
newPortLines.map(line => line.toPort?.id),
);
oldPortLines
// 若两条线的终点相同则放弃交换,
// 1. 没必要交换,交换后的连线是相同的
// 2. 过程中会生成两条相同连线,但是画布中不允许有两条相同连线,会导致一条连线丢失
.filter(
line => !intersectionToPort.some(portId => portId === line.toPort?.id),
)
.forEach(line => {
this.linesManager.replaceLine(line.info, {
...newPortInfo,
to: line.info.to,
});
});
newPortLines
.filter(
line => !intersectionToPort.some(portId => portId === line.toPort?.id),
)
.forEach(line => {
this.linesManager.replaceLine(line.info, {
...oldPortInfo,
to: line.info.to,
});
});
this.linesManager.forceUpdate();
}
/** 监听拖拽空线条时 */
private onDragLineEnd(): Disposable {
return this.dragService.onDragLineEnd(
async (params: {
fromPort: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
mousePos: PositionSchema;
line?: WorkflowLineEntity;
originLine?: WorkflowLineEntity;
event: PlaygroundDragEvent;
}) => {
const { fromPort, toPort, mousePos, line, originLine } = params;
if (originLine || !line) {
return;
}
if (toPort) {
return;
}
await this.nodePanelService.call({
fromPort,
toPort: undefined,
panelPosition: mousePos,
enableBuildLine: true,
panelProps: {
enableNodePlaceholder: true,
enableScrollClose: true,
},
canAddNode: ({ nodeType, containerNode }) => {
const canDropMessage = this.dragService.canDropToNode({
dragNodeType: nodeType as StandardNodeType,
dropNode: containerNode,
});
if (!canDropMessage.allowDrop) {
Toast.warning({
content: canDropMessage.message,
});
}
return canDropMessage.allowDrop;
},
});
},
);
}
}

View File

@@ -0,0 +1,94 @@
/*
* 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 { WorkflowDocument } from '@flowgram-adapter/free-layout-editor';
import { getLLMModels } from '@coze-workflow/nodes';
import { PUBLIC_SPACE_ID } from '@coze-workflow/base';
import { type Model } from '@coze-arch/bot-api/developer_api';
import { WorkflowGlobalStateEntity } from '@/entities';
@injectable()
export class WorkflowModelsService {
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
@inject(WorkflowDocument)
readonly document: WorkflowDocument;
protected models: Model[] = [];
async load() {
// TODO: 临时方案,模版空间为虚拟空间,只读模式不加载模型列表,解决权限问题
if (
this.globalState.readonly &&
this.globalState.spaceId === PUBLIC_SPACE_ID
) {
this.models = [];
return;
}
this.models = await getLLMModels({
info: this.globalState.info,
spaceId: this.globalState.spaceId,
document: this.document,
isBindDouyin: this.globalState.isBindDouyin,
});
}
getModels() {
return this.models;
}
/**
* 获取所有的COT模型
* @returns 返回所有的COT模型
*/
getCoTModels() {
return this.models.filter(model => model.model_ability?.cot_display);
}
/**
* 判断是否是COT模型
* @param modelType
* @returns
*/
isCoTModel(modelType: number): boolean {
return !!this.getCoTModels().find(model => model.model_type === modelType);
}
/**
* 判断是否是FunctionCall模型
*/
isFunctionCallModel(modelType: number): boolean {
return !!this.getModelAbility(modelType)?.function_call;
}
/**
* 根据类型获取模型
* @param modelType
* @returns
*/
getModelByType(modelType?: number) {
return this.models.find(model => model.model_type === modelType);
}
/**
* 获取模型能力
* @param modelType
* @returns
*/
getModelAbility(modelType?: number) {
return this.getModelByType(modelType)?.model_ability;
}
}

View File

@@ -0,0 +1,284 @@
/*
* 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 @typescript-eslint/no-explicit-any */
import { set } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { type WorkflowJSON } from '@flowgram-adapter/free-layout-editor';
import { PUBLIC_SPACE_ID } from '@coze-workflow/base/constants';
import {
workflowApi,
WorkflowMode,
type CopyWorkflowData,
type WorkFlowTestRunData,
type GetWorkflowProcessResponse,
type GetWorkflowProcessRequest,
type Workflow,
type ReleasedWorkflow,
type WorkflowNodeTypeData,
type WorkflowNodeDebugV2Request,
VCSCanvasType,
} from '@coze-workflow/base/api';
import { reporter } from '@coze-arch/logger';
import { getFlags } from '@coze-arch/bot-flags';
import { type PublishWorkflowRequest } from '@coze-arch/bot-api/workflow_api';
enum MockTrafficEnabled {
DISABLE = 0,
ENABLE = 1,
}
import {
WorkflowGlobalStateEntity,
WorkflowDependencyStateEntity,
} from '../entities';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const WorkflowOperationServiceProvider = Symbol(
'WorkflowOperationServiceProvider',
);
/**
* workflow增删改查等操作接口调用
* 由于多人协作和非多人模式存在两套不同接口,将判断逻辑统一收敛到这里, 减少脏代码入侵
*/
@injectable()
export class WorkflowOperationService {
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
@inject(WorkflowDependencyStateEntity)
readonly dependencyEntity: WorkflowDependencyStateEntity;
get spaceId() {
return this.globalState.spaceId;
}
get workflowId() {
return this.globalState.workflowId;
}
get logId() {
return this.globalState.logId;
}
get mocksetFgOption() {
const options = {
headers: {
'rpc-persist-mock-traffic-enable': MockTrafficEnabled.ENABLE,
},
};
return options;
}
publish = async (obj: Partial<PublishWorkflowRequest> = {}) => {
try {
let published = false;
this.globalState.updateConfig({ publishing: true });
const data = await workflowApi.PublishWorkflow({
workflow_id: this.workflowId,
space_id: this.spaceId,
// 已废弃,待删除
has_collaborator: false,
// 发布都不经过 testrun 校验
force: true,
...obj,
});
published = !!data.data?.success;
reporter.successEvent({
eventName: 'workflow_publish_success',
namespace: 'workflow',
});
return published;
} catch (error) {
reporter.errorEvent({
eventName: 'workflow_publish_fail',
namespace: 'workflow',
error,
});
return false;
} finally {
this.globalState.updateConfig({ publishing: false });
}
};
copy = async (): Promise<
Pick<CopyWorkflowData, 'workflow_id'> | undefined
> => {
if (this.globalState.info.workflowSourceSpaceId === PUBLIC_SPACE_ID) {
const resp = await workflowApi.CopyWkTemplateApi(
{
target_space_id: this.spaceId,
workflow_ids: [this.workflowId || ''],
},
{
__disableErrorToast: true,
},
);
return { workflow_id: resp?.data?.[this.workflowId]?.workflow_id ?? '' };
} else {
const { data } = await workflowApi.CopyWorkflow({
space_id: this.spaceId,
workflow_id: this.workflowId,
});
return data;
}
};
save = async (json: WorkflowJSON, ignoreStatusTransfer: boolean) => {
const FLAGS = getFlags();
const { vcsData } = this.globalState.info;
const { saveVersion } = this.dependencyEntity;
const reqParams = {
schema: JSON.stringify(json),
workflow_id: this.workflowId,
space_id: this.spaceId,
submit_commit_id: vcsData?.submit_commit_id || '',
ignore_status_transfer: ignoreStatusTransfer,
};
// 仅 project 内需要
if (
this.globalState.projectId &&
FLAGS?.['bot.automation.project_multi_tab']
) {
set(reqParams, 'save_version', saveVersion.toString());
}
await workflowApi.SaveWorkflow(reqParams);
if (this.globalState.projectId) {
// 为了解决 canvas 接口获取 saveVersion 和 长链推送 saveVersion 不同步的问题,在这里手动更新
this.dependencyEntity.addSaveVersion();
}
};
testRun = async ({
baseParam,
input,
}): Promise<WorkFlowTestRunData | undefined> => {
const options = {
...this.mocksetFgOption,
};
// 1. 查看历史试运行时需要传入commitId. 2.协作模式下非草稿态时试运行传入commitId防止回退为草稿
const commitId =
this.globalState.isViewHistory ||
(this.globalState.isCollaboratorMode &&
this.globalState.info.vcsData?.type !== VCSCanvasType.Draft)
? this.globalState.info.vcsData?.submit_commit_id
: '';
const { data } = await workflowApi.WorkFlowTestRun(
{
...baseParam,
commit_id: commitId,
input,
},
options,
);
return data;
};
testOneNode = async (params: WorkflowNodeDebugV2Request) =>
await workflowApi.WorkflowNodeDebugV2(params, this.mocksetFgOption);
getProcess = async (
executeId?: string,
subExecuteId?: string,
): Promise<GetWorkflowProcessResponse> => {
const params: GetWorkflowProcessRequest = {
workflow_id: this.workflowId,
space_id: this.spaceId,
execute_id: executeId,
sub_execute_id: subExecuteId,
};
if (this.logId) {
params.log_id = this.logId;
}
// 如果是子流程的日志,暂时不走异步查询,后端有 bug
params.need_async = !subExecuteId;
const executeResult = await workflowApi.GetWorkFlowProcess(params);
return executeResult;
};
queryNodeType = async (): Promise<WorkflowNodeTypeData | undefined> => {
const params = {
workflow_id: this.workflowId,
space_id: this.spaceId,
};
const { data } = await workflowApi.QueryWorkflowNodeTypes(params);
const nodeTypes = data;
return nodeTypes;
};
getSubWorkflowList = async ({
workflowIds,
filterType,
pageParam,
name,
size,
}: any): Promise<ReleasedWorkflow[] | undefined> => {
const { data } = await workflowApi.GetReleasedWorkflows({
workflow_ids: workflowIds,
space_id: this.spaceId,
type: filterType,
page: pageParam,
name,
size,
flow_mode: WorkflowMode.All,
});
const releasedList = data.workflow_list;
return releasedList?.filter(item => item.workflow_id !== this.workflowId);
};
getReference = async (): Promise<Workflow[]> => {
const params = {
workflow_id: this.workflowId,
space_id: this.spaceId,
};
const { data } = await workflowApi.GetWorkflowReferences(params);
const referenceList = data.workflow_list || [];
return referenceList;
};
validateSchema = async (
json: any,
bind?: {
projectId?: string;
botId?: string;
},
) => {
const { botId, projectId } = bind || {};
const { data } = await workflowApi.ValidateSchema({
schema: JSON.stringify(json),
bind_project_id: projectId,
bind_bot_id: botId,
});
return data;
};
}

View File

@@ -0,0 +1,724 @@
/*
* 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-lines */
/* eslint-disable @typescript-eslint/naming-convention */
import { omit } from 'lodash-es';
import { inject, injectable, postConstruct } from 'inversify';
import { type FeedbackStatus } from '@flowgram-adapter/free-layout-editor';
import { Playground } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
WorkflowLinesManager,
} from '@flowgram-adapter/free-layout-editor';
import { Emitter } from '@flowgram-adapter/common';
import { workflowApi } from '@coze-workflow/base/api';
import {
type GetWorkFlowProcessData,
NodeExeStatus,
type NodeResult,
WorkflowExeStatus,
} from '@coze-workflow/base/api';
import { reporter } from '@coze-arch/logger';
import { sleep } from '@coze-arch/bot-utils';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import {
WorkflowExecStateEntity,
WorkflowExecStatus,
WorkflowGlobalStateEntity,
WorkflowTestFormStateEntity,
} from '../entities';
import { type TestFormDefaultValue } from '../components/test-run/types';
import { START_NODE_ID } from '../components/test-run/constants';
import { WorkflowOperationService } from './workflow-operation-service';
import { TestRunReporterService } from './test-run-reporter-service';
const LOOP_GAP_TIME = 300;
export enum TestRunState {
/** 空置状态 */
Idle = 'idle',
/** 执行中 */
Executing = 'executing',
/** 取消 */
Canceled = 'canceled',
/** 暂停 */
Paused = 'paused',
/** 成功 */
Succeed = 'succeed',
// 失败
Failed = 'failed',
}
const ExecuteStatusToTestRunStateMap = {
[WorkflowExeStatus.Cancel]: TestRunState.Canceled,
[WorkflowExeStatus.Fail]: TestRunState.Failed,
[WorkflowExeStatus.Success]: TestRunState.Succeed,
};
export interface TestRunResultInfo {
// 执行id
executeId?: string;
}
interface TestRunOneNodeOptions {
nodeId: string;
input?: Record<string, string>;
batch?: Record<string, string>;
setting?: Record<string, string>;
botId?: string;
/** 是否选择应用 */
useProject?: boolean;
}
/**
* Workflow 执行
*/
@injectable()
export class WorkflowRunService {
@inject(WorkflowDocument) protected document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
@inject(WorkflowExecStateEntity) readonly execState: WorkflowExecStateEntity;
@inject(WorkflowTestFormStateEntity)
readonly testFormState: WorkflowTestFormStateEntity;
@inject(WorkflowLinesManager)
protected readonly linesManager: WorkflowLinesManager;
@inject(WorkflowOperationService)
protected readonly operationService: WorkflowOperationService;
@inject(TestRunReporterService)
protected readonly reporter: TestRunReporterService;
@inject(Playground) protected playground: Playground;
protected readonly testRunStateEmitter = new Emitter<{
prevState: TestRunState;
curState: TestRunState;
}>();
readonly onTestRunStateChange = this.testRunStateEmitter.event;
private _testRunState: TestRunState = TestRunState.Idle;
public get testRunState() {
return this._testRunState;
}
private setTestRunState(state: TestRunState) {
if (state === this.testRunState) {
return;
}
this.testRunStateEmitter.fire({
prevState: this.testRunState,
curState: state,
});
this._testRunState = state;
}
@postConstruct()
protected init(): void {
/**
* 画布销毁的时候触发
*/
this.playground.toDispose.onDispose(() => this.dispose());
this.onTestRunStateChange(({ prevState, curState }) => {
// 监听state变化, 对testrun结果进行上报
this.reportTestRunResult(prevState, curState);
});
}
dispose() {
/** 画布销毁,清除所有 testForm 缓存数据 */
this.testFormState.clearFormData();
this.testFormState.clearTestFormDefaultValue();
/** 销毁时解冻 test run */
this.testFormState.unfreezeTestRun();
// 清空运行状态
this.clearTestRun();
}
clearTestRun = () => {
this.clearTestRunResult();
this.clearTestRunState();
};
clearTestRunResult = () => {
this.testFormState.unfreezeTestRun();
this.globalState.viewStatus = WorkflowExecStatus.DEFAULT;
// 清空节点结果,避免 test run 的时候回显
if (this.execState.hasNodeResult) {
this.execState.clearNodeResult();
}
this.execState.clearNodeErrorMap();
this.execState.updateConfig({
executeLogId: undefined,
systemError: undefined,
isSingleMode: undefined,
executeId: '',
});
this.execState.clearNodeEvents();
};
/**
* 清空运行状态
*/
clearTestRunState = () => {
this.setTestRunState(TestRunState.Idle);
};
cancelTestRun = async () => {
const { executeId } = this.execState.config;
sendTeaEvent(EVENT_NAMES.workflow_testrun_result_front, {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
testrun_id: executeId || '',
action: 'manual_end',
});
try {
await workflowApi.CancelWorkFlow({
execute_id: executeId || '',
workflow_id: this.globalState.workflowId,
space_id: this.globalState.spaceId,
});
// eslint-disable-next-line no-useless-catch
} catch (e) {
throw e;
} finally {
// 无论是否取消成功,中止状态下取消,需要恢复轮训
this.continueTestRun();
}
};
updateExecuteState = ({
nodeResults,
executeStatus,
executeId,
reason,
projectId,
nodeEvents,
}: GetWorkFlowProcessData): void => {
// 更新各个节点状态
if (nodeResults && nodeResults.length) {
nodeResults.forEach((nodeResult: NodeResult) => {
const { nodeId } = nodeResult;
if (nodeId) {
this.execState.setNodeExecResult(nodeId, nodeResult);
// 更新节点的线条状态
const currentLines = this.linesManager
.getAllLines()
.filter(line => line?.to?.id === nodeResult.nodeId);
currentLines.forEach(currentLine => {
const fromNodeStatus = nodeResults.find(
node => node.nodeId === currentLine.from.id,
)?.nodeStatus;
currentLine.processing = Boolean(
nodeResult.nodeStatus === NodeExeStatus.Running &&
(fromNodeStatus === NodeExeStatus.Success ||
fromNodeStatus === NodeExeStatus.Running),
);
});
// 更新节点错误
const errorLevel = nodeResult.errorLevel?.toLocaleLowerCase() || '';
if (['error', 'warning', 'pending'].includes(errorLevel || '')) {
this.execState.setNodeError(nodeId, [
{
nodeId,
errorInfo: nodeResult.errorInfo || '',
errorLevel: errorLevel as FeedbackStatus,
errorType: 'node',
},
]);
}
}
});
}
this.execState.setNodeEvents(nodeEvents);
this.execState.updateConfig({
projectId,
executeLogId: executeId,
// 仅当运行结果为 cancel 或者 fail 时 reason 字段才有效
systemError:
executeStatus &&
[WorkflowExeStatus.Cancel, WorkflowExeStatus.Fail].includes(
executeStatus,
)
? reason
: '',
});
};
/**
* 获取执行结果, 以及对服务返回数据进行预处理.
* 可支持用执行 ID 获取, 或者直接传入服务返回数据
*/
runProcess = async ({
executeId,
processResp,
subExecuteId,
}: {
executeId?: string;
processResp?: GetWorkFlowProcessData;
subExecuteId?: string;
}): Promise<GetWorkFlowProcessData> => {
const data = processResp
? processResp
: (await this.operationService.getProcess(executeId, subExecuteId)).data;
// 一个远古的 bug ,后端返回 Warn ,前端消费 warning 。改代码影响面太大,因此在这里做一下转换
data?.nodeResults?.forEach(node => {
if (node.errorLevel === 'Warn') {
node.errorLevel = 'warning';
}
try {
if (node.batch) {
const batchResults = JSON.parse(node.batch);
node.batch = JSON.stringify(
batchResults.map(d => ({
...d,
errorLevel: d.errorLevel === 'Warn' ? 'warning' : d.errorLevel,
})),
);
}
} catch (e) {
console.error(e);
}
});
return data || {};
};
// 暂停test run
pauseTestRun = () => {
this.setTestRunState(TestRunState.Paused);
};
// 继续test run
continueTestRun = () => {
if (this.testRunState === TestRunState.Paused) {
this.setTestRunState(TestRunState.Executing);
}
};
protected waitContinue = async () => {
if (this.testRunState !== TestRunState.Paused) {
return;
}
return new Promise(resolve => {
this.onTestRunStateChange(({ curState }) => {
if (curState !== TestRunState.Paused) {
resolve(true);
}
});
});
};
// copy 原本的逻辑,每过 300ms 进行一次轮询
loop = async (executeId?: string) => {
const result = await this.runProcess({ executeId });
this.execState.updateConfig(result || {});
this.updateExecuteState(result);
if (result?.executeStatus !== WorkflowExeStatus.Running) {
return result?.executeStatus;
}
await Promise.all([sleep(LOOP_GAP_TIME), this.waitContinue()]);
return this.loop(executeId);
};
finishProcess = () => {
this.testFormState.unfreezeTestRun();
this.globalState.viewStatus = WorkflowExecStatus.DONE;
if (!this.globalState.isViewHistory) {
// 根据试运行结果,刷新可发布状态
this.globalState.reload();
}
};
/* 上报test run运行结果, 用于在商店中统计运行成功率 */
reportTestRunResult = (prevState: TestRunState, curState: TestRunState) => {
const { executeId, isSingleMode } = this.execState.config;
// 单节点模式不需要统计
if (isSingleMode) {
return;
}
if (![TestRunState.Succeed, TestRunState.Failed].includes(curState)) {
return;
}
/* 成功 */
if (curState === TestRunState.Succeed) {
sendTeaEvent(EVENT_NAMES.workflow_testrun_result_front, {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
testrun_id: executeId,
action: 'testrun_end',
results: 'success',
});
}
if (curState === TestRunState.Failed) {
/* 触发失败 */
if (prevState === TestRunState.Idle) {
sendTeaEvent(EVENT_NAMES.workflow_testrun_result_front, {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
action: 'testrun_end',
results: 'fail',
fail_end: 'server_end',
errtype: 'trigger_error',
});
}
/* 运行失败 */
sendTeaEvent(EVENT_NAMES.workflow_testrun_result_front, {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
testrun_id: executeId,
action: 'testrun_end',
results: 'fail',
fail_end: 'server_end',
errtype: 'run_error',
});
}
};
/**
* 运行 test run
*/
testRun = async (
input?: Record<string, string>,
botId?: string,
/** 当前选择的是否为应用 */
useProject?: boolean,
) => {
if (this.globalState.config.saving) {
return;
}
this.testFormState.freezeTestRun(START_NODE_ID);
let executeStatus;
let executeId = '';
try {
this.execState.closeSideSheet();
// 更新视图为运行中
this.globalState.viewStatus = WorkflowExecStatus.EXECUTING;
const baseParam: {
workflow_id: string;
space_id: string;
bot_id?: string;
project_id?: string;
} = {
workflow_id: this.globalState.workflowId,
space_id: this.globalState.spaceId,
};
/** 存在 variable 节点或者存在 variable 节点子流程的画布才需要传 bot_id与 input 同级*/
if (botId) {
const botIdKey = useProject ? 'project_id' : 'bot_id';
baseParam[botIdKey] = botId;
}
// 如果是在 project 内,则 projectId 必传
if (this.globalState.projectId && !baseParam.project_id) {
baseParam.project_id = this.globalState.projectId;
}
const result = await this.operationService
.testRun({ baseParam, input })
.catch(e => {
sendTeaEvent(EVENT_NAMES.workflow_testrun_result_front, {
space_id: this.globalState.spaceId,
workflow_id: this.globalState.workflowId,
action: 'testrun_end',
results: 'fail',
fail_end: 'server_end',
errtype: 'trigger_error',
});
throw { ...e, errtype: 'trigger_error' };
});
executeId = result?.execute_id || '';
// 有执行 idtest run 运行成功
if (executeId) {
this.execState.updateConfig({
executeId,
});
this.setTestRunState(TestRunState.Executing);
executeStatus = await this.loop(executeId);
this.finishProcess();
}
this.reporter.runEnd({
testrun_type: 'flow',
testrun_result:
this.reporter.utils.executeStatus2TestRunResult(executeStatus),
execute_id: executeId,
});
} catch (error) {
executeStatus = WorkflowExeStatus.Fail;
this.clearTestRunResult();
this.execState.updateConfig({
systemError: error.msg || error.message,
});
this.reporter.runEnd({
testrun_type: 'flow',
testrun_result: 'error',
});
} finally {
// 运行失败打开弹窗
if (executeStatus === WorkflowExeStatus.Fail) {
this.execState.openSideSheet();
}
// 运行后,不管成功,还原 inPluginUpdated 值,避免一直 testrun
this.globalState.inPluginUpdated = false;
this.setTestRunState(ExecuteStatusToTestRunStateMap[executeStatus]);
}
};
/**
* 获取执行历史数据
*/
getProcessResult = async (config: {
/** 是否展示节点结果 */
showNodeResults?: boolean;
/** 指定执行 ID, 若不填, 则展示最近一次运行结果 */
executeId?: string;
/** 直接结果服务端返回 */
processResp?: GetWorkFlowProcessData;
/** 子流程执行id */
subExecuteId?: string;
}) => {
const { showNodeResults, executeId, processResp, subExecuteId } = config;
try {
const result = await this.runProcess({
executeId,
processResp,
subExecuteId,
});
this.execState.updateConfig(omit(result, 'nodeEvents'));
if (showNodeResults) {
/**
* 只把结果数据同步到节点上,但是不改变 global.config.info.status
* 否则上一次 test run 的结果会影响到现在流程是否能够发布的状态
* 该状态只能由真正的 test run、后端数据等真实动作修改
*/
this.globalState.viewStatus = WorkflowExecStatus.DONE;
this.updateExecuteState(omit(result, 'nodeEvents'));
}
return result;
} catch (e) {
reporter.errorEvent({
eventName: 'workflow_get_process_result_fail',
namespace: 'workflow',
error: e,
});
throw e;
}
};
getViewStatus() {
return this.globalState.viewStatus;
}
/**
* 获取当前 test form schema 对应的 node
*/
getTestFormNode() {
const schema = this.testFormState.formSchema;
if (!schema || !schema.id) {
return null;
}
return this.document.getNode(schema.id) || null;
}
/**
* 单节点运行
*/
async testRunOneNode(options: TestRunOneNodeOptions) {
const { nodeId, input, batch, setting, botId, useProject } = options;
if (this.globalState.config.saving) {
return;
}
this.execState.updateConfig({
isSingleMode: true,
});
this.testFormState.freezeTestRun(nodeId);
this.execState.closeSideSheet();
let executeStatus;
try {
this.globalState.viewStatus = WorkflowExecStatus.EXECUTING;
const botIdParams = {};
if (botId) {
if (useProject) {
Object.assign(botIdParams, { project_id: botId });
} else {
Object.assign(botIdParams, { bot_id: botId });
}
}
// 在项目内的话,默认使用 project_id
if (this.globalState.projectId) {
Object.assign(botIdParams, { project_id: this.globalState.projectId });
}
const res = await this.operationService.testOneNode({
workflow_id: this.globalState.workflowId,
space_id: this.globalState.spaceId,
node_id: nodeId,
input,
batch,
setting,
...botIdParams,
});
const executeId = res.data?.execute_id;
if (executeId) {
this.setTestRunState(TestRunState.Executing);
this.execState.updateConfig({ executeId });
executeStatus = await this.loop(executeId);
this.finishProcess();
}
this.reporter.runEnd({
testrun_type: 'node',
testrun_result:
this.reporter.utils.executeStatus2TestRunResult(executeStatus),
execute_id: executeId,
});
} catch (error) {
executeStatus = WorkflowExeStatus.Fail;
this.clearTestRunResult();
this.reporter.runEnd({
testrun_type: 'node',
testrun_result: 'error',
});
throw error;
} finally {
this.setTestRunState(ExecuteStatusToTestRunStateMap[executeStatus]);
}
}
setTestFormDefaultValue = (defaultValue: TestFormDefaultValue[]) => {
this.testFormState.setTestFormDefaultValue(defaultValue);
};
/** 轮询获取实时的数据 */
async getRTProcessResult(obj: { executeId?: string }) {
const { executeId } = obj;
if (!executeId) {
return;
}
let executeStatus;
try {
/** 直接请求一次数据 */
const result = await this.runProcess({ executeId });
/** 更新数据到视图 */
this.execState.updateConfig(result || {});
this.updateExecuteState(result);
executeStatus = result?.executeStatus;
/**
* 当仍然处于运行中,则开启轮询
* chatflow 场景,后端可能返回 0这时候也需要执行轮询
*/
if (
result?.executeStatus === WorkflowExeStatus.Running ||
(result?.executeStatus as number) === 0
) {
/** 开启轮询,需要先讲视图变为只读态 */
this.globalState.viewStatus = WorkflowExecStatus.EXECUTING;
this.setTestRunState(TestRunState.Executing);
/**
* 1. 为保证请求的节奏同步,这里也会 sleep
* 2. 暂停同样对其有效,不过暂时没有这种业务场景
*/
await Promise.all([sleep(LOOP_GAP_TIME), this.waitContinue()]);
/** 轮询 */
executeStatus = await this.loop(executeId);
}
this.finishProcess();
} catch (error) {
executeStatus = WorkflowExeStatus.Fail;
this.clearTestRunResult();
this.execState.updateConfig({
systemError: error.msg || error.message,
});
} finally {
this.globalState.inPluginUpdated = false;
this.setTestRunState(ExecuteStatusToTestRunStateMap[executeStatus]);
}
}
async testRunTrigger(triggerId: string) {
if (this.globalState.config.saving || !this.globalState.projectId) {
return;
}
this.testFormState.freezeTestRun(triggerId);
let executeStatus;
let executeId = '';
try {
this.globalState.viewStatus = WorkflowExecStatus.EXECUTING;
const result = await workflowApi.TestRunTrigger({
space_id: this.globalState.spaceId,
project_id: this.globalState.projectId,
trigger_id: triggerId,
});
executeId = result?.data?.execute_id || '';
if (executeId) {
this.execState.updateConfig({
executeId,
});
this.setTestRunState(TestRunState.Executing);
executeStatus = await this.loop(executeId);
this.finishProcess();
}
this.reporter.runEnd({
testrun_type: 'trigger',
testrun_result:
this.reporter.utils.executeStatus2TestRunResult(executeStatus),
execute_id: executeId,
});
} catch (error) {
executeStatus = WorkflowExeStatus.Fail;
this.clearTestRunResult();
this.execState.updateConfig({
systemError: error.msg || error.message,
});
this.reporter.runEnd({
testrun_type: 'trigger',
testrun_result: 'error',
});
} finally {
this.globalState.inPluginUpdated = false;
this.setTestRunState(ExecuteStatusToTestRunStateMap[executeStatus]);
}
}
}

View File

@@ -0,0 +1,635 @@
/*
* 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 { debounce, isEmpty } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { StackingContextManager } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import {
Playground,
PlaygroundConfigEntity,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowContentChangeType,
WorkflowLineEntity,
WorkflowResetLayoutService,
delay,
type WorkflowContentChangeEvent,
type WorkflowDocument,
type WorkflowJSON,
} from '@flowgram-adapter/free-layout-editor';
import { Emitter, type Disposable } from '@flowgram-adapter/common';
import { GlobalVariableService } from '@coze-workflow/variable';
import { WorkflowNodesService } from '@coze-workflow/nodes';
import { useWorkflowStore } from '@coze-workflow/base/store';
import { OperateType, WorkflowMode } from '@coze-workflow/base/api';
import {
StandardNodeType,
type WorkflowNodeJSON,
reporter,
} from '@coze-workflow/base';
import { userStoreService } from '@coze-studio/user-store';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { getFlags } from '@coze-arch/bot-flags';
import { CustomError } from '@coze-arch/bot-error';
import { type Model } from '@coze-arch/bot-api/developer_api';
import { WorkflowModelsService } from '@/services/workflow-models-service';
import { TriggerService } from '@/services/trigger-service';
import { RelatedCaseDataService } from '@/services/related-case-data-service';
import { getNodeV2Registry } from '@/nodes-v2';
import { WorkflowPlaygroundContext } from '../workflow-playground-context';
import {
WorkflowGlobalStateEntity,
WorkflowDependencyStateEntity,
} from '../entities';
import { WorkflowOperationService } from './workflow-operation-service';
// 这个非写死,不要用来判断开始节点,请用 flowNodeType
const START_NODE_ID = '100001';
const END_NODE_ID = '900001';
const CHAT_NODE_DEFAULT_ID = '110100';
const HIGH_DEBOUNCE_TIME = 1000;
const LOW_DEBOUNCE_TIME = 3000;
const RELOAD_DELAY_TIME = 500;
const RENDER_DELAY_TIME = 100;
const ERROR_CODE_SAVE_VERSION_CONFLICT = '720702239';
/**
* 创建默认,只有 start 和 end 节点
*/
function createDefaultJSON(flowMode: WorkflowMode): WorkflowJSON {
if (flowMode === WorkflowMode.SceneFlow) {
return {
nodes: [
{
id: START_NODE_ID,
type: StandardNodeType.Start,
meta: {
position: { x: 0, y: 0 },
},
data: {
outputs: [
{
type: 'string',
name: '',
required: true,
},
],
},
},
{
id: END_NODE_ID,
type: StandardNodeType.End,
meta: {
position: { x: 2000, y: 0 },
},
},
{
id: CHAT_NODE_DEFAULT_ID,
type: StandardNodeType.SceneChat,
meta: {
position: { x: 1000, y: 0 },
},
},
],
edges: [
{ sourceNodeID: START_NODE_ID, targetNodeID: CHAT_NODE_DEFAULT_ID },
],
};
} else {
return {
nodes: [
{
id: START_NODE_ID,
type: StandardNodeType.Start,
meta: {
position: { x: 0, y: 0 },
},
data: {
outputs: [
{
type: 'string',
name: '',
required: true,
},
],
},
},
{
id: END_NODE_ID,
type: StandardNodeType.End,
meta: {
position: { x: 1000, y: 0 },
},
},
],
edges: [],
};
}
}
@injectable()
export class WorkflowSaveService {
@inject(WorkflowGlobalStateEntity) globalState: WorkflowGlobalStateEntity;
@inject(WorkflowNodesService) nodesService: WorkflowNodesService;
@inject(WorkflowOperationService) operationService: WorkflowOperationService;
@inject(WorkflowModelsService) modelsService: WorkflowModelsService;
@inject(TriggerService) triggerService: TriggerService;
@inject(GlobalVariableService) globalVariableService: GlobalVariableService;
@inject(RelatedCaseDataService) relatedBotService: RelatedCaseDataService;
@inject(WorkflowDependencyStateEntity)
dependencyEntity: WorkflowDependencyStateEntity;
@inject(WorkflowPlaygroundContext) context: WorkflowPlaygroundContext;
@inject(PlaygroundConfigEntity) playgroundConfig: PlaygroundConfigEntity;
@inject(WorkflowResetLayoutService)
resetLayoutService: WorkflowResetLayoutService;
@inject(Playground) playground: Playground;
@inject(StackingContextManager)
private readonly stackingContextManager: StackingContextManager;
protected workflowDocument: WorkflowDocument;
protected readonly onSavedEmitter = new Emitter<void>();
readonly onSaved = this.onSavedEmitter.event;
protected saveOnChangeDisposable?: Disposable;
// 是否需要流转 test run 状态。当修改节点位置时,不需要重新 test run
ignoreStatusTransfer = true;
/**
* 获取 workflow schema json
* @param commitId 流程的版本信息
* @param type 流程版本的类型信息 提交或发布
*/
loadWorkflowJson = async (
commitId?: string,
type?: OperateType,
env?: string,
) => {
let workflowJson: WorkflowJSON | undefined;
const {
workflowId,
spaceId,
workflowCommitId,
playgroundProps,
flowMode,
logId,
projectId,
projectCommitVersion,
} = this.globalState;
const FLAGS = getFlags();
const needUseLogId = IS_BOT_OP && logId;
const isPreviewInProject = Boolean(projectId && projectCommitVersion);
const hasExecuteId = Boolean(playgroundProps?.executeId);
// 如果是 project 查看历史版本
if (isPreviewInProject) {
workflowJson = await this.globalState.loadHistory({
commit_id: commitId as string,
project_version: projectCommitVersion as string,
project_id: projectId as string,
log_id: logId,
type: type || OperateType.SubmitOperate,
env,
});
} else if (commitId || needUseLogId || hasExecuteId) {
workflowJson = await this.globalState.loadHistory({
commit_id: commitId as string,
log_id: logId,
type: type || OperateType.SubmitOperate,
execute_id: hasExecuteId ? playgroundProps?.executeId : undefined,
sub_execute_id: playgroundProps?.subExecuteId,
env,
});
} else if (workflowCommitId || needUseLogId) {
workflowJson = await this.globalState.loadHistory({
commit_id: workflowCommitId,
log_id: logId,
type:
this.globalState.config?.playgroundProps?.commitOptType ||
OperateType.SubmitOperate,
});
} else if (playgroundProps?.from === 'communityTrial') {
/**
* 如果来自 community trial 无论是否有 commitId 都要取历史的版本
* 主要应对在 trial 中 db 没有 commitId 请求最新 schema 的场景。数据清洗完毕后可删除该逻辑
*/
workflowJson = await this.globalState.loadHistory({
commit_id: workflowCommitId,
type:
this.globalState.config?.playgroundProps?.commitOptType ||
OperateType.SubmitOperate,
});
} else {
workflowJson = await this.globalState.load(workflowId, spaceId);
// 流程初始化设置 saveVersion
const workflowInfo = this.globalState.config?.info;
FLAGS?.['bot.automation.project_multi_tab'] &&
projectId &&
this.dependencyEntity.setSaveVersion(
BigInt(workflowInfo?.save_version ?? ''),
);
}
if (!workflowJson || workflowJson.nodes.length === 0) {
workflowJson = createDefaultJSON(flowMode);
}
if (!workflowJson.edges) {
workflowJson.edges = [];
}
return workflowJson;
};
/**
* 对所有节点表单渲染前进行初始化,初始化完毕才会进行表单创建工作
* @param nodes 所有节点数据
*/
async initNodeData(nodes: WorkflowNodeJSON[]) {
const promises: Promise<void>[] = [];
const stack: WorkflowNodeJSON[] = [...nodes];
while (stack.length > 0) {
const node = stack.pop() as WorkflowNodeJSON;
const registry = getNodeV2Registry(node.type as StandardNodeType);
if (registry?.onInit) {
promises.push(registry.onInit(node, this.context));
}
if (node.blocks && node.blocks.length > 0) {
stack.push(...node.blocks);
}
}
await Promise.all(promises);
}
/**
* 加载文档数据
*/
async loadDocument(doc: WorkflowDocument): Promise<void> {
this.workflowDocument = doc;
const { workflowId, getProjectApi } = this.globalState;
const projectApi = getProjectApi();
const loadingStartTime = Date.now();
try {
if (!workflowId) {
throw Error(I18n.t('workflow_detail_error_interface_initialization'));
}
projectApi?.setWidgetUIState('saving');
this.hideRenderLayer();
const userInfo = userStoreService.getUserInfo();
const locale = userInfo?.locale ?? navigator.language ?? 'en-US';
// 加载节点信息
const [, workflowJSON] = await Promise.all([
this.context.loadNodeInfos(locale),
this.loadWorkflowJson(),
// this.loadGlobalVariables(),
]);
await this.loadGlobalVariables(workflowJSON);
await this.modelsService.load();
try {
await this.triggerService.load();
} catch (e) {
logger.error(e.message);
}
// 加载大模型上下文
this.context.models = this.modelsService.getModels() as Model[];
// 同步加载的 nodes 和 edges 到 workflow store
useWorkflowStore.getState().setNodes(workflowJSON.nodes);
useWorkflowStore.getState().setEdges(workflowJSON.edges);
const loadDateTime = Date.now() - loadingStartTime;
const renderStartTime = Date.now();
// 前置数据加载
await this.initNodeData(workflowJSON.nodes as WorkflowNodeJSON[]);
await this.workflowDocument.fromJSON(workflowJSON);
const renderTime = Date.now() - renderStartTime;
this.globalState.updateConfig({
loading: false,
});
projectApi?.setWidgetUIState('normal');
// 有权限才能自动保存
if (!this.globalState.readonly) {
this.saveOnChangeDisposable = doc.onContentChange(
this.listenContentChange,
);
}
const fitViewStartTime = Date.now();
await this.fitView();
const fitViewTime = Date.now() - fitViewStartTime;
const totalTime = Date.now() - loadingStartTime;
reporter.event({
eventName: 'workflow_load_document',
meta: {
totalTime,
loadDateTime,
renderTime,
fitViewTime,
},
});
} catch (e) {
this.globalState.updateConfig({
loadingError: e.message,
loading: false,
});
projectApi?.setWidgetUIState('error');
throw e;
} finally {
this.showRenderLayer();
}
}
async loadGlobalVariables(workflowJSON?: WorkflowJSON) {
const useNewGlobalVariableCache =
!this.globalState.isInIDE &&
!this.globalState.playgroundProps?.disableGetTestCase;
if (useNewGlobalVariableCache) {
const relatedBot =
await this.relatedBotService.getAsyncRelatedBotValue(workflowJSON);
if (!relatedBot?.id || !relatedBot?.type) {
return;
}
return this.globalVariableService.loadGlobalVariables(
relatedBot?.type,
relatedBot?.id,
);
}
return this.globalVariableService.loadGlobalVariables(
'project',
this.globalState.projectId,
);
}
/**
* 默认将开始节点居中展示
*/
async fitView(): Promise<void> {
// 等待节点渲染与布局计算
await delay(RENDER_DELAY_TIME);
// 等待 DOM resize 更新
await new Promise<void>(resolve => {
window.requestAnimationFrame(() => resolve());
});
// 执行布局
this.workflowDocument.fitView(false);
// 等待布局后节点渲染
await delay(RENDER_DELAY_TIME);
}
/**
* 保存文档数据
*/
save = async () => {
const { getProjectApi } = this.globalState;
const projectApi = getProjectApi();
const FLAGS = getFlags();
try {
// 只读态禁用保存,如果在加载状态中,也禁止保存
if (this.globalState.readonly || this.globalState.loading) {
return;
}
reporter.event({
eventName: 'workflow_save',
});
this.globalState.updateConfig({
saveLoading: true,
saving: true,
savingError: false,
});
projectApi?.setWidgetUIState('saving');
const json = await this.workflowDocument.toJSON();
if (this.globalState.config.schemaGray) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 临时字段
(json as any).versions = this.globalState.config.schemaGray;
}
// 保存草稿,存储 workflow 的 nodes 和 edges 到 zustand store
useWorkflowStore.getState().setNodes(json.nodes);
useWorkflowStore.getState().setEdges(json.edges);
// FIXME 这个问题还没定位清除,先阻止保存
if (json.nodes.length === 0) {
projectApi?.setWidgetUIState('error');
throw new CustomError(REPORT_EVENTS.parmasValidation, 'Saving Error');
}
await this.operationService.save(json, this.ignoreStatusTransfer);
this.ignoreStatusTransfer = true;
await this.globalState.reload();
this.globalState.updateConfig({
saveLoading: false,
saving: false,
savingError: false,
});
projectApi?.setWidgetUIState('normal');
// save 成功后获取最新的 saveVersion
const workflowInfo = this.globalState.config.info;
FLAGS?.['bot.automation.project_multi_tab'] &&
this.globalState.projectId &&
this.dependencyEntity.setSaveVersion(
BigInt(workflowInfo?.save_version ?? ''),
);
} catch (e) {
this.globalState.updateConfig({
saveLoading: false,
saving: false,
savingError: true,
});
projectApi?.setWidgetUIState('error');
// 新版本节点后续统一使用 e.name 是否为 CustomNodeError 来判断
if (
e.eventName === 'WorkflowSubWorkflowResourceLose' ||
e.eventName === 'WorkflowApiNodeResourceLose' ||
e?.name === 'CustomNodeError'
) {
logger.warning(e.message);
} else if (
e.code === ERROR_CODE_SAVE_VERSION_CONFLICT &&
FLAGS?.['bot.automation.project_multi_tab']
) {
// 保存时发现工作流版本冲突,提示用户刷新页面
this.dependencyEntity.setRefreshModalVisible(true);
throw e;
} else {
throw e;
}
} finally {
this.onSavedEmitter.fire();
}
};
/**
* 判断是否为游离节点的改动
* 游离节点修改无需重新 test run
*/
isAssociateChange(entity: FlowNodeEntity | WorkflowLineEntity) {
let isAssociateChange = false;
const associatedNodes = this.workflowDocument.getAssociatedNodes();
if (entity instanceof FlowNodeEntity) {
isAssociateChange = associatedNodes.some(node => node.id === entity.id);
} else if (entity instanceof WorkflowLineEntity) {
isAssociateChange = associatedNodes.some(
node => node.id === entity.from.id,
);
}
return isAssociateChange;
}
protected listenContentChange = ({
type,
entity,
}: WorkflowContentChangeEvent) => {
const { getProjectApi } = this.globalState;
const projectApi = getProjectApi();
this.globalState.updateConfig({
saving: true,
});
projectApi?.setWidgetUIState('saving');
const isAssociateChange = this.isAssociateChange(entity);
if (
type === WorkflowContentChangeType.MOVE_NODE ||
type === WorkflowContentChangeType.META_CHANGE ||
!isAssociateChange
) {
this.lowPrioritySave();
} else {
this.ignoreStatusTransfer = false;
this.highPrioritySave();
}
};
/**
* 高优先级保存,包含节点内容、节点增删、线条增删
*/
public highPrioritySave = debounce(() => {
reporter.event({
eventName: 'workflow_high_priority_save',
});
this.save();
}, HIGH_DEBOUNCE_TIME);
/**
* 低优先级保存,包含节点位置移动
* @protected
*/
public lowPrioritySave = debounce(() => {
reporter.event({
eventName: 'workflow_low_priority_save',
});
this.highPrioritySave();
}, LOW_DEBOUNCE_TIME - HIGH_DEBOUNCE_TIME);
waitSaving = () => {
if (!this.globalState.config.saving) {
return;
}
return new Promise(resolve => {
this.onSaved(() => resolve(true));
});
};
/**
* 重载文档数据
*/
reloadDocument = async ({
commitId,
type,
env,
customWorkflowJson,
}: {
commitId?: string;
type?: OperateType;
env?: string;
customWorkflowJson?: WorkflowJSON;
} = {}) => {
// 等待 save 结束
await this.waitSaving();
const workflowJson = !isEmpty(customWorkflowJson)
? customWorkflowJson
: await this.loadWorkflowJson(commitId, type, env);
if (!workflowJson) {
return;
}
this.hideRenderLayer();
this.saveOnChangeDisposable?.dispose();
// 前置数据加载
await this.initNodeData((workflowJson?.nodes as WorkflowNodeJSON[]) ?? []);
await this.workflowDocument.reload(workflowJson, RELOAD_DELAY_TIME);
if (!this.globalState.readonly) {
this.saveOnChangeDisposable = this.workflowDocument.onContentChange(
this.listenContentChange,
);
}
await this.fitView();
this.showRenderLayer();
};
private hideRenderLayer(): void {
this.stackingContextManager.node.style.opacity = '0';
}
private showRenderLayer(): void {
this.stackingContextManager.node.style.opacity = '1';
}
}

View File

@@ -0,0 +1,482 @@
/*
* 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 { createWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/shallow';
import { inject, injectable } from 'inversify';
import {
FlowNodeFormData,
getNodeError,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowNodeMeta,
type WorkflowNodeEntity,
WorkflowNodePortsData,
type WorkflowPortEntity,
WorkflowNodeLinesData,
} from '@flowgram-adapter/free-layout-editor';
import { GlobalVariableService } from '@coze-workflow/variable';
import { SETTING_ON_ERROR_PORT } from '@coze-workflow/nodes';
import {
type ValidationService,
type ValidationState,
type ValidateErrorMap,
type ValidateError,
type ValidateResult,
type WorkflowValidateErrorMap,
} from '@coze-workflow/base/services';
import { workflowApi, StandardNodeType } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import {
ValidateErrorType,
type ValidateTreeRequest,
type ValidateErrorData,
type ValidateTreeInfo,
} from '@coze-arch/bot-api/workflow_api';
import { WorkflowGlobalStateEntity } from '../entities';
const createStore = () =>
createWithEqualityFn<ValidationState>(
() => ({
errors: {},
errorsV2: {},
validating: false,
}),
shallow,
);
const isLineError = (data: ValidateErrorData) =>
data.type === ValidateErrorType.BotConcurrentPathErr ||
data.type === ValidateErrorType.BotValidatePathErr;
const formatSchemaError2ValidateError = (
data?: ValidateErrorData[],
): ValidateErrorMap => {
const map: ValidateErrorMap = {};
if (!data) {
return map;
}
data.forEach(item => {
const { node_error, path_error } = item;
const isLine = isLineError(item);
const nodeId = (isLine ? path_error?.start : node_error?.node_id) || '';
const targetNodeId = isLine ? path_error?.end : undefined;
const errorType = isLine ? 'line' : 'node';
const error: ValidateError = {
nodeId,
targetNodeId,
errorType,
errorInfo: item.message || '',
errorLevel: 'error',
};
const errors = map[nodeId] || [];
errors.push(error);
map[nodeId] = errors;
});
return map;
};
const formatSchemaError2WorkflowError = (data?: ValidateTreeInfo[]) => {
const map: WorkflowValidateErrorMap = {};
if (!data) {
return map;
}
data.forEach(item => {
const { errors, workflow_id, ...rest } = item;
if (!errors?.length || !workflow_id) {
return;
}
map[workflow_id] = {
workflowId: workflow_id,
...rest,
errors: formatSchemaError2ValidateError(errors),
};
});
return map;
};
/**
* Workflow 执行状态校验服务
*/
@injectable()
export class WorkflowValidationService implements ValidationService {
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
readonly globalState: WorkflowGlobalStateEntity;
@inject(GlobalVariableService)
private readonly globalVariable: GlobalVariableService;
store = createStore();
/** 已校验节点 */
public validatedNodeMap: Record<string, boolean> = {};
/** 清除已校验节点状态 */
public clearValidatedNodeMap() {
this.validatedNodeMap = {};
}
/** 校验节点 */
public async validateNode(node: WorkflowNodeEntity): Promise<ValidateResult> {
const nodeErrorResult = this.validateNodeError(node);
const formValidateResult = await this.validateForm(node);
const subCanvasPortValidateResult = this.validateSubCanvasPort(node);
const settingOnErrorResult = this.validateSettingOnErrorPort(node);
const validateResults = [
nodeErrorResult,
formValidateResult,
subCanvasPortValidateResult,
settingOnErrorResult,
].filter(Boolean) as ValidateResult[];
return this.mergeValidateResult(...validateResults);
}
/** 校验工作流 */
public async validateWorkflow(): Promise<ValidateResult> {
const nodes = this.document.getAssociatedNodes();
const results: ValidateResult[] = await Promise.all(
nodes.map(this.validateNode.bind(this)),
);
return this.mergeValidateResult(...results);
}
/** 合并校验结果 */
private mergeValidateResult(...result: ValidateResult[]): ValidateResult {
const hasError = result.some(item => item.hasError);
const nodeErrorMap = result.reduce<ValidateErrorMap>(
(errorMap, nodeValidateResult) => {
if (!nodeValidateResult.hasError) {
return errorMap;
}
Object.entries(nodeValidateResult.nodeErrorMap).forEach(
([nodeId, nodeErrors]) => {
errorMap[nodeId] = errorMap[nodeId] ?? [];
errorMap[nodeId].push(...nodeErrors);
},
);
return errorMap;
},
{},
);
return {
hasError,
nodeErrorMap,
};
}
/** 校验节点错误 */
private validateNodeError(
node: WorkflowNodeEntity,
): ValidateResult | undefined {
const invalidError = getNodeError(node);
if (!invalidError) {
return;
}
return {
hasError: true,
nodeErrorMap: {
[node.id]: [
{
errorInfo: invalidError.message,
errorLevel: 'error',
errorType: 'node',
nodeId: node.id,
},
],
},
};
}
/** 校验节点表单 */
private async validateForm(
node: WorkflowNodeEntity,
): Promise<ValidateResult | undefined> {
const invalidError = getNodeError(node);
const formData = node?.getData<FlowNodeFormData>(FlowNodeFormData);
if (invalidError || !formData.formModel.initialized) {
return;
}
// 节点表单校验
const feedbacks = await formData.formModel.validateWithFeedbacks();
const nodeFormError = feedbacks
.filter(
feedback =>
feedback.feedbackStatus === 'warning' ||
feedback.feedbackStatus === 'error',
)
.map(feedback => {
let feedbackText = feedback.feedbackText || '';
// output的feedbacks需要解析, 暂时没有好的办法判断不同的feedbacks
try {
const parsedError = JSON.parse(feedbackText);
const { issues, name } = parsedError;
if (name === 'ZodError' && issues?.[0]?.message) {
feedbackText = issues?.[0].message;
}
} catch (e) {
console.log(e);
}
return {
errorInfo: feedbackText,
errorLevel: feedback.feedbackStatus,
errorType: 'node',
nodeId: node.id,
};
}) as ValidateError[];
const hasError = !!nodeFormError.length;
this.validatedNodeMap[node.id] = true;
const formValidateResult: ValidateResult = {
hasError,
nodeErrorMap: {
[node.id]: nodeFormError,
},
};
return formValidateResult;
}
/** 校验子画布端口 */
private validateSubCanvasPort(
node: WorkflowNodeEntity,
): ValidateResult | undefined {
const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();
const subCanvas = nodeMeta.subCanvas?.(node);
if (!subCanvas || !subCanvas.isCanvas) {
return;
}
const { parentNode, canvasNode } = subCanvas;
const portsData = node.getData<WorkflowNodePortsData>(
WorkflowNodePortsData,
);
const { allPorts: ports } = portsData;
const inputPort = ports.find(port =>
String(port.portID).endsWith('function-inline-output'),
);
const outputPort = ports.find(port =>
String(port.portID).endsWith('function-inline-input'),
);
if (!inputPort || !outputPort) {
return;
}
const isInputPortEmpty = inputPort.allLines.length === 0;
const isOutputPortEmpty = outputPort.allLines.length === 0;
const errors: ValidateError[] = [];
if (isInputPortEmpty) {
const errorMessage =
parentNode.flowNodeType === StandardNodeType.Loop
? I18n.t('workflow_testrun_check list_loopbody_start_unconnect')
: I18n.t('workflow_testrun_check list_batchbody_start_unconnect');
errors.push({
errorInfo: errorMessage,
errorLevel: 'error',
errorType: 'node',
nodeId: node.id,
});
(
inputPort as WorkflowPortEntity & {
errorMessage?: string;
}
).errorMessage = errorMessage;
inputPort.hasError = true;
} else {
inputPort.hasError = false;
}
// 所有叶子结点都是结束节点
const isAllLeafEnds = canvasNode.collapsedChildren
.filter(
childNode =>
childNode.getData(WorkflowNodeLinesData).outputNodes.length === 0,
)
.every(childNode => childNode.getNodeMeta().isNodeEnd);
if (isOutputPortEmpty && !isAllLeafEnds) {
const errorMessage =
parentNode.flowNodeType === StandardNodeType.Loop
? I18n.t('workflow_testrun_check list_loopbody_end_unconnect')
: I18n.t('workflow_testrun_check list_batchbody_end_unconnect');
errors.push({
errorInfo: errorMessage,
errorLevel: 'error',
errorType: 'node',
nodeId: node.id,
});
(
outputPort as WorkflowPortEntity & {
errorMessage?: string;
}
).errorMessage = errorMessage;
outputPort.hasError = true;
} else {
outputPort.hasError = false;
}
return {
hasError: !!errors.length,
nodeErrorMap: {
[node.id]: errors,
},
};
}
/**
* 校验异常设置端口
*/
private validateSettingOnErrorPort(
node: WorkflowNodeEntity,
): ValidateResult | undefined {
const portsData = node.getData<WorkflowNodePortsData>(
WorkflowNodePortsData,
);
const { allPorts: ports } = portsData;
const settingOnErrorPort = ports.find(
port => String(port.portID) === SETTING_ON_ERROR_PORT,
);
if (!settingOnErrorPort) {
return;
}
const isSettingOnErrorEmpty = settingOnErrorPort.allLines.length === 0;
const errors: ValidateError[] = [];
if (isSettingOnErrorEmpty) {
const errorMessage = I18n.t(
'workflow_250407_214',
undefined,
'需要完善节点的异常处理流程',
);
errors.push({
errorInfo: errorMessage,
errorLevel: 'error',
errorType: 'node',
nodeId: node.id,
});
(
settingOnErrorPort as WorkflowPortEntity & {
errorMessage?: string;
}
).errorMessage = errorMessage;
settingOnErrorPort.hasError = true;
} else {
settingOnErrorPort.hasError = false;
}
return {
hasError: !!errors.length,
nodeErrorMap: {
[node.id]: errors,
},
};
}
/** 校验画布 schema */
public async validateSchema() {
const json = await this.document.toJSON();
const params =
this.globalVariable.state.type === 'project'
? {
bind_project_id: this.globalVariable.state.id,
}
: {
bind_bot_id: this.globalVariable.state.id,
};
const { data } = await workflowApi.ValidateSchema({
schema: JSON.stringify(json),
...params,
});
const hasError = !!data?.length;
const nodeErrorMap = formatSchemaError2ValidateError(data);
return {
hasError,
nodeErrorMap,
};
}
/** 新版校验画布 schema */
public async validateSchemaV2() {
const params: ValidateTreeRequest = {
workflow_id: this.globalState.workflowId,
};
if (this.globalVariable.state.type === 'project') {
params.bind_project_id = this.globalVariable.state.id;
} else {
params.bind_bot_id = this.globalVariable.state.id;
}
const json = await this.document.toJSON();
const { data } = await workflowApi.ValidateTree({
schema: JSON.stringify(json),
...params,
});
const errors = formatSchemaError2WorkflowError(data);
const hasError = !!Object.keys(errors).length;
return {
hasError,
errors,
};
}
getErrors(id: string) {
return this.store.getState().errors[id] || [];
}
setErrors(errors: ValidateErrorMap, force?: boolean) {
const prev = this.store.getState().errors;
const next = force ? errors : { ...prev, ...errors };
this.store.setState({
errors: next,
});
}
clearErrors() {
this.store.setState({ errors: {}, errorsV2: {} });
}
setErrorsV2(errors: WorkflowValidateErrorMap) {
this.store.setState({
errorsV2: errors,
});
}
isLineError(fromId: string, toId?: string) {
const errors = this.store.getState().errorsV2;
/** 仅查找本 workflow 的线条错误 */
const myErrors = errors[this.globalState.workflowId]?.errors;
if (!myErrors) {
return false;
}
const nodeErrors = myErrors[fromId] || [];
const lineError = nodeErrors.find(
error => error.errorType === 'line' && error.targetNodeId === toId,
);
return !!lineError;
}
/** 非响应式,慎用 */
get validating() {
return this.store.getState().validating;
}
set validating(value: boolean) {
this.store.setState({
validating: value,
});
}
}