feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
40
frontend/packages/workflow/playground/src/services/index.ts
Normal file
40
frontend/packages/workflow/playground/src/services/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_v1),apiName 不会变还是 getStock,name 才是更新后的的 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 || '';
|
||||
// 有执行 id,test 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user