feat(flows): 新增流程编辑器基础功能与相关组件
feat(backend): 添加流程模型与服务支持 feat(frontend): 实现流程编辑器UI与交互 feat(assets): 添加流程节点图标资源 feat(plugins): 实现上下文菜单和运行时插件 feat(components): 新增基础节点和侧边栏组件 feat(routes): 添加流程相关路由配置 feat(models): 创建流程和运行日志数据模型 feat(services): 实现流程服务层逻辑 feat(migration): 添加流程相关数据库迁移 feat(config): 更新前端配置支持流程编辑器 feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
|
||||
import {
|
||||
Layer,
|
||||
injectable,
|
||||
inject,
|
||||
FreeLayoutPluginContext,
|
||||
WorkflowHoverService,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeJSON,
|
||||
WorkflowSelectService,
|
||||
WorkflowDocument,
|
||||
PositionSchema,
|
||||
WorkflowDragService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { ContainerUtils } from '@flowgram.ai/free-container-plugin';
|
||||
|
||||
@injectable()
|
||||
export class ContextMenuLayer extends Layer {
|
||||
@inject(FreeLayoutPluginContext) ctx: FreeLayoutPluginContext;
|
||||
|
||||
@inject(WorkflowNodePanelService) nodePanelService: WorkflowNodePanelService;
|
||||
|
||||
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
|
||||
|
||||
@inject(WorkflowSelectService) selectService: WorkflowSelectService;
|
||||
|
||||
@inject(WorkflowDocument) document: WorkflowDocument;
|
||||
|
||||
@inject(WorkflowDragService) dragService: WorkflowDragService;
|
||||
|
||||
onReady() {
|
||||
this.listenPlaygroundEvent('contextmenu', (e) => {
|
||||
if (this.config.readonlyOrDisabled) return;
|
||||
this.openNodePanel(e);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
openNodePanel(e: MouseEvent) {
|
||||
const mousePos = this.getPosFromMouseEvent(e);
|
||||
const containerNode = this.getContainerNode(mousePos);
|
||||
this.nodePanelService.callNodePanel({
|
||||
position: mousePos,
|
||||
containerNode,
|
||||
panelProps: {},
|
||||
// handle node selection from panel - 处理从面板中选择节点
|
||||
onSelect: async (panelParams?: NodePanelResult) => {
|
||||
if (!panelParams) {
|
||||
return;
|
||||
}
|
||||
const { nodeType, nodeJSON } = panelParams;
|
||||
const position = this.dragService.adjustSubNodePosition(nodeType, containerNode, mousePos);
|
||||
// create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
|
||||
const node: WorkflowNodeEntity = this.ctx.document.createWorkflowNodeByType(
|
||||
nodeType,
|
||||
position,
|
||||
nodeJSON ?? ({} as WorkflowNodeJSON),
|
||||
containerNode?.id
|
||||
);
|
||||
// select the newly created node - 选择新创建的节点
|
||||
this.selectService.select(node);
|
||||
},
|
||||
// handle panel close - 处理面板关闭
|
||||
onClose: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private getContainerNode(mousePos: PositionSchema): WorkflowNodeEntity | undefined {
|
||||
const allNodes = this.document.getAllNodes();
|
||||
const containerTransforms = ContainerUtils.getContainerTransforms(allNodes);
|
||||
const collisionTransform = ContainerUtils.getCollisionTransform({
|
||||
targetPoint: mousePos,
|
||||
transforms: containerTransforms,
|
||||
document: this.document,
|
||||
});
|
||||
return collisionTransform?.entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
definePluginCreator,
|
||||
PluginCreator,
|
||||
FreeLayoutPluginContext,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { ContextMenuLayer } from './context-menu-layer';
|
||||
|
||||
export interface ContextMenuPluginOptions {}
|
||||
|
||||
/**
|
||||
* Creates a plugin of contextmenu
|
||||
* @param ctx - The plugin context, containing the document and other relevant information.
|
||||
* @param options - Plugin options, currently an empty object.
|
||||
*/
|
||||
export const createContextMenuPlugin: PluginCreator<ContextMenuPluginOptions> = definePluginCreator<
|
||||
ContextMenuPluginOptions,
|
||||
FreeLayoutPluginContext
|
||||
>({
|
||||
onInit(ctx, options) {
|
||||
ctx.playground.registerLayer(ContextMenuLayer);
|
||||
},
|
||||
});
|
||||
6
frontend/src/flows/plugins/context-menu-plugin/index.ts
Normal file
6
frontend/src/flows/plugins/context-menu-plugin/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createContextMenuPlugin } from './context-menu-plugin';
|
||||
8
frontend/src/flows/plugins/index.ts
Normal file
8
frontend/src/flows/plugins/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createContextMenuPlugin } from './context-menu-plugin';
|
||||
export { createRuntimePlugin } from './runtime-plugin';
|
||||
export { createVariablePanelPlugin } from './variable-panel-plugin';
|
||||
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';
|
||||
import { injectable } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
@injectable()
|
||||
export class WorkflowRuntimeClient implements IRuntimeClient {
|
||||
constructor() {}
|
||||
|
||||
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun];
|
||||
|
||||
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport];
|
||||
|
||||
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult];
|
||||
|
||||
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel];
|
||||
|
||||
public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate];
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';
|
||||
import { injectable } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
@injectable()
|
||||
export class WorkflowRuntimeBrowserClient implements IRuntimeClient {
|
||||
constructor() {}
|
||||
|
||||
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = async (input) => {
|
||||
const { TaskRunAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
|
||||
return TaskRunAPI(input);
|
||||
};
|
||||
|
||||
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = async (
|
||||
input
|
||||
) => {
|
||||
const { TaskReportAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
|
||||
return TaskReportAPI(input);
|
||||
};
|
||||
|
||||
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = async (
|
||||
input
|
||||
) => {
|
||||
const { TaskResultAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
|
||||
return TaskResultAPI(input);
|
||||
};
|
||||
|
||||
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = async (
|
||||
input
|
||||
) => {
|
||||
const { TaskCancelAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
|
||||
return TaskCancelAPI(input);
|
||||
};
|
||||
|
||||
public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate] = async (
|
||||
input
|
||||
) => {
|
||||
const { TaskValidateAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
|
||||
return TaskValidateAPI(input);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { WorkflowRuntimeClient } from './base-client';
|
||||
export { WorkflowRuntimeBrowserClient } from './browser-client';
|
||||
export { WorkflowRuntimeServerClient } from './server-client';
|
||||
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ServerConfig } from '../../type';
|
||||
|
||||
export const DEFAULT_SERVER_CONFIG: ServerConfig = {
|
||||
domain: 'localhost',
|
||||
port: 4000,
|
||||
protocol: 'http',
|
||||
};
|
||||
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowGramAPIName,
|
||||
IRuntimeClient,
|
||||
TaskCancelDefine,
|
||||
TaskCancelInput,
|
||||
TaskCancelOutput,
|
||||
TaskReportDefine,
|
||||
TaskReportInput,
|
||||
TaskReportOutput,
|
||||
TaskResultDefine,
|
||||
TaskResultInput,
|
||||
TaskResultOutput,
|
||||
TaskRunDefine,
|
||||
TaskRunInput,
|
||||
TaskRunOutput,
|
||||
TaskValidateDefine,
|
||||
TaskValidateInput,
|
||||
TaskValidateOutput,
|
||||
} from '@flowgram.ai/runtime-interface';
|
||||
import { injectable } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { ServerConfig } from '../../type';
|
||||
import type { ServerError } from './type';
|
||||
import { DEFAULT_SERVER_CONFIG } from './constant';
|
||||
|
||||
@injectable()
|
||||
export class WorkflowRuntimeServerClient implements IRuntimeClient {
|
||||
private config: ServerConfig = DEFAULT_SERVER_CONFIG;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public init(config: ServerConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public async [FlowGramAPIName.TaskRun](input: TaskRunInput): Promise<TaskRunOutput | undefined> {
|
||||
return this.request<TaskRunOutput>(TaskRunDefine.path, TaskRunDefine.method, {
|
||||
body: input,
|
||||
errorMessage: 'TaskRun failed',
|
||||
});
|
||||
}
|
||||
|
||||
public async [FlowGramAPIName.TaskReport](
|
||||
input: TaskReportInput
|
||||
): Promise<TaskReportOutput | undefined> {
|
||||
return this.request<TaskReportOutput>(TaskReportDefine.path, TaskReportDefine.method, {
|
||||
queryParams: { taskID: input.taskID },
|
||||
errorMessage: 'TaskReport failed',
|
||||
});
|
||||
}
|
||||
|
||||
public async [FlowGramAPIName.TaskResult](
|
||||
input: TaskResultInput
|
||||
): Promise<TaskResultOutput | undefined> {
|
||||
return this.request<TaskResultOutput>(TaskResultDefine.path, TaskResultDefine.method, {
|
||||
queryParams: { taskID: input.taskID },
|
||||
errorMessage: 'TaskResult failed',
|
||||
fallbackValue: { success: false },
|
||||
});
|
||||
}
|
||||
|
||||
public async [FlowGramAPIName.TaskCancel](input: TaskCancelInput): Promise<TaskCancelOutput> {
|
||||
const result = await this.request<TaskCancelOutput>(
|
||||
TaskCancelDefine.path,
|
||||
TaskCancelDefine.method,
|
||||
{
|
||||
body: input,
|
||||
errorMessage: 'TaskCancel failed',
|
||||
fallbackValue: { success: false },
|
||||
}
|
||||
);
|
||||
return result ?? { success: false };
|
||||
}
|
||||
|
||||
public async [FlowGramAPIName.TaskValidate](
|
||||
input: TaskValidateInput
|
||||
): Promise<TaskValidateOutput | undefined> {
|
||||
return this.request<TaskValidateOutput>(TaskValidateDefine.path, TaskValidateDefine.method, {
|
||||
body: input,
|
||||
errorMessage: 'TaskValidate failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Generic request method to reduce code duplication
|
||||
private async request<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
options: {
|
||||
body?: unknown;
|
||||
queryParams?: Record<string, string>;
|
||||
errorMessage: string;
|
||||
fallbackValue?: T;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
const url = this.url(path, options.queryParams);
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
redirect: 'follow',
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
requestOptions.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
requestOptions.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
const output: T | ServerError = await response.json();
|
||||
|
||||
if (this.isError(output)) {
|
||||
console.error(options.errorMessage, output);
|
||||
return options.fallbackValue;
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return options.fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
private url(path: string, queryParams?: Record<string, string>): string {
|
||||
const baseURL = this.getURL(`/api${path}`);
|
||||
if (!queryParams) {
|
||||
return baseURL;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(queryParams);
|
||||
return `${baseURL}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private isError(output: unknown | undefined): output is ServerError {
|
||||
return !!output && (output as ServerError).code !== undefined;
|
||||
}
|
||||
|
||||
private getURL(path: string): string {
|
||||
const protocol = this.config.protocol ?? window.location.protocol;
|
||||
const host = this.config.port
|
||||
? `${this.config.domain}:${this.config.port}`
|
||||
: this.config.domain;
|
||||
return `${protocol}://${host}${path}`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export interface ServerError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { definePluginCreator, PluginContext } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { RuntimePluginOptions } from './type';
|
||||
import { WorkflowRuntimeService } from './runtime-service';
|
||||
import {
|
||||
WorkflowRuntimeBrowserClient,
|
||||
WorkflowRuntimeClient,
|
||||
WorkflowRuntimeServerClient,
|
||||
} from './client';
|
||||
|
||||
export const createRuntimePlugin = definePluginCreator<RuntimePluginOptions, PluginContext>({
|
||||
onBind({ bind, rebind }, options) {
|
||||
bind(WorkflowRuntimeClient).toSelf().inSingletonScope();
|
||||
bind(WorkflowRuntimeServerClient).toSelf().inSingletonScope();
|
||||
bind(WorkflowRuntimeBrowserClient).toSelf().inSingletonScope();
|
||||
if (options.mode === 'server') {
|
||||
rebind(WorkflowRuntimeClient).to(WorkflowRuntimeServerClient);
|
||||
} else {
|
||||
rebind(WorkflowRuntimeClient).to(WorkflowRuntimeBrowserClient);
|
||||
}
|
||||
bind(WorkflowRuntimeService).toSelf().inSingletonScope();
|
||||
},
|
||||
onInit(ctx, options) {
|
||||
if (options.mode === 'server') {
|
||||
const serverClient = ctx.get<WorkflowRuntimeServerClient>(WorkflowRuntimeClient);
|
||||
serverClient.init(options.serverConfig);
|
||||
}
|
||||
},
|
||||
});
|
||||
7
frontend/src/flows/plugins/runtime-plugin/index.ts
Normal file
7
frontend/src/flows/plugins/runtime-plugin/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createRuntimePlugin } from './create-runtime-plugin';
|
||||
export { WorkflowRuntimeClient } from './client';
|
||||
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
IReport,
|
||||
NodeReport,
|
||||
WorkflowInputs,
|
||||
WorkflowOutputs,
|
||||
WorkflowStatus,
|
||||
} from '@flowgram.ai/runtime-interface';
|
||||
import {
|
||||
injectable,
|
||||
inject,
|
||||
WorkflowDocument,
|
||||
Playground,
|
||||
WorkflowLineEntity,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeLinesData,
|
||||
Emitter,
|
||||
getNodeForm,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { WorkflowRuntimeClient } from '../client';
|
||||
import { WorkflowNodeType } from '../../../nodes';
|
||||
|
||||
const SYNC_TASK_REPORT_INTERVAL = 500;
|
||||
|
||||
interface NodeRunningStatus {
|
||||
nodeID: string;
|
||||
status: WorkflowStatus;
|
||||
nodeResultLength: number;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class WorkflowRuntimeService {
|
||||
@inject(Playground) playground: Playground;
|
||||
|
||||
@inject(WorkflowDocument) document: WorkflowDocument;
|
||||
|
||||
@inject(WorkflowRuntimeClient) runtimeClient: WorkflowRuntimeClient;
|
||||
|
||||
private runningNodes: WorkflowNodeEntity[] = [];
|
||||
|
||||
private taskID?: string;
|
||||
|
||||
private syncTaskReportIntervalID?: ReturnType<typeof setInterval>;
|
||||
|
||||
private reportEmitter = new Emitter<NodeReport>();
|
||||
|
||||
private resetEmitter = new Emitter<{}>();
|
||||
|
||||
private resultEmitter = new Emitter<{
|
||||
errors?: string[];
|
||||
result?: {
|
||||
inputs: WorkflowInputs;
|
||||
outputs: WorkflowOutputs;
|
||||
};
|
||||
}>();
|
||||
|
||||
private nodeRunningStatus: Map<string, NodeRunningStatus>;
|
||||
|
||||
public onNodeReportChange = this.reportEmitter.event;
|
||||
|
||||
public onReset = this.resetEmitter.event;
|
||||
|
||||
public onResultChanged = this.resultEmitter.event;
|
||||
|
||||
public isFlowingLine(line: WorkflowLineEntity) {
|
||||
return this.runningNodes.some((node) =>
|
||||
node.getData(WorkflowNodeLinesData).inputLines.includes(line)
|
||||
);
|
||||
}
|
||||
|
||||
public async taskRun(inputs: WorkflowInputs): Promise<string | undefined> {
|
||||
if (this.taskID) {
|
||||
await this.taskCancel();
|
||||
}
|
||||
const isFormValid = await this.validateForm();
|
||||
if (!isFormValid) {
|
||||
this.resultEmitter.fire({
|
||||
errors: ['Form validation failed'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const schema = this.document.toJSON();
|
||||
const validateResult = await this.runtimeClient.TaskValidate({
|
||||
schema: JSON.stringify(schema),
|
||||
inputs,
|
||||
});
|
||||
if (!validateResult?.valid) {
|
||||
this.resultEmitter.fire({
|
||||
errors: validateResult?.errors ?? ['Internal Server Error'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
let taskID: string | undefined;
|
||||
try {
|
||||
const output = await this.runtimeClient.TaskRun({
|
||||
schema: JSON.stringify(schema),
|
||||
inputs,
|
||||
});
|
||||
taskID = output?.taskID;
|
||||
} catch (e) {
|
||||
this.resultEmitter.fire({
|
||||
errors: [(e as Error)?.message],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!taskID) {
|
||||
this.resultEmitter.fire({
|
||||
errors: ['Task run failed'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.taskID = taskID;
|
||||
this.syncTaskReportIntervalID = setInterval(() => {
|
||||
this.syncTaskReport();
|
||||
}, SYNC_TASK_REPORT_INTERVAL);
|
||||
return this.taskID;
|
||||
}
|
||||
|
||||
public async taskCancel(): Promise<void> {
|
||||
if (!this.taskID) {
|
||||
return;
|
||||
}
|
||||
await this.runtimeClient.TaskCancel({
|
||||
taskID: this.taskID,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateForm(): Promise<boolean> {
|
||||
const allForms = this.document.getAllNodes().map((node) => getNodeForm(node));
|
||||
const formValidations = await Promise.all(allForms.map(async (form) => form?.validate()));
|
||||
const validations = formValidations.filter((validation) => validation !== undefined);
|
||||
const isValid = validations.every((validation) => validation);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.taskID = undefined;
|
||||
this.nodeRunningStatus = new Map();
|
||||
this.runningNodes = [];
|
||||
if (this.syncTaskReportIntervalID) {
|
||||
clearInterval(this.syncTaskReportIntervalID);
|
||||
}
|
||||
this.resetEmitter.fire({});
|
||||
}
|
||||
|
||||
private async syncTaskReport(): Promise<void> {
|
||||
if (!this.taskID) {
|
||||
return;
|
||||
}
|
||||
const report = await this.runtimeClient.TaskReport({
|
||||
taskID: this.taskID,
|
||||
});
|
||||
if (!report) {
|
||||
clearInterval(this.syncTaskReportIntervalID);
|
||||
console.error('Sync task report failed');
|
||||
return;
|
||||
}
|
||||
const { workflowStatus, inputs, outputs, messages } = report;
|
||||
if (workflowStatus.terminated) {
|
||||
clearInterval(this.syncTaskReportIntervalID);
|
||||
if (Object.keys(outputs).length > 0) {
|
||||
this.resultEmitter.fire({ result: { inputs, outputs } });
|
||||
} else {
|
||||
this.resultEmitter.fire({
|
||||
errors: messages?.error?.map((message) =>
|
||||
message.nodeID ? `${message.nodeID}: ${message.message}` : message.message
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
this.updateReport(report);
|
||||
}
|
||||
|
||||
private updateReport(report: IReport): void {
|
||||
const { reports } = report;
|
||||
this.runningNodes = [];
|
||||
this.document
|
||||
.getAllNodes()
|
||||
.filter(
|
||||
(node) =>
|
||||
![WorkflowNodeType.BlockStart, WorkflowNodeType.BlockEnd].includes(
|
||||
node.flowNodeType as WorkflowNodeType
|
||||
)
|
||||
)
|
||||
.forEach((node) => {
|
||||
const nodeID = node.id;
|
||||
const nodeReport = reports[nodeID];
|
||||
if (!nodeReport) {
|
||||
return;
|
||||
}
|
||||
if (nodeReport.status === WorkflowStatus.Processing) {
|
||||
this.runningNodes.push(node);
|
||||
}
|
||||
const runningStatus = this.nodeRunningStatus.get(nodeID);
|
||||
if (
|
||||
!runningStatus ||
|
||||
nodeReport.status !== runningStatus.status ||
|
||||
nodeReport.snapshots.length !== runningStatus.nodeResultLength
|
||||
) {
|
||||
this.nodeRunningStatus.set(nodeID, {
|
||||
nodeID,
|
||||
status: nodeReport.status,
|
||||
nodeResultLength: nodeReport.snapshots.length,
|
||||
});
|
||||
this.reportEmitter.fire(nodeReport);
|
||||
this.document.linesManager.forceUpdate();
|
||||
} else if (nodeReport.status === WorkflowStatus.Processing) {
|
||||
this.reportEmitter.fire(nodeReport);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
21
frontend/src/flows/plugins/runtime-plugin/type.ts
Normal file
21
frontend/src/flows/plugins/runtime-plugin/type.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export interface RuntimeBrowserOptions {
|
||||
mode?: 'browser';
|
||||
}
|
||||
|
||||
export interface RuntimeServerOptions {
|
||||
mode: 'server';
|
||||
serverConfig: ServerConfig;
|
||||
}
|
||||
|
||||
export type RuntimePluginOptions = RuntimeBrowserOptions | RuntimeServerOptions;
|
||||
|
||||
export interface ServerConfig {
|
||||
domain: string;
|
||||
port?: number;
|
||||
protocol?: string;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useVariableTree } from '@flowgram.ai/form-materials';
|
||||
import { Tree } from '@douyinfe/semi-ui';
|
||||
|
||||
export function FullVariableList() {
|
||||
const treeData = useVariableTree({});
|
||||
|
||||
return <Tree treeData={treeData} />;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
BaseVariableField,
|
||||
GlobalScope,
|
||||
useRefresh,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { JsonSchemaEditor, JsonSchemaUtils } from '@flowgram.ai/form-materials';
|
||||
|
||||
export function GlobalVariableEditor() {
|
||||
const globalScope = useService(GlobalScope);
|
||||
|
||||
const refresh = useRefresh();
|
||||
|
||||
const globalVar = globalScope.getVar() as BaseVariableField;
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = globalScope.output.onVariableListChange(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!globalVar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };
|
||||
|
||||
return (
|
||||
<JsonSchemaEditor
|
||||
value={value}
|
||||
onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
.panel-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.variable-panel-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
z-index: 1;
|
||||
|
||||
&.close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
width: 500px;
|
||||
border-radius: 5px;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 30;
|
||||
|
||||
:global(.semi-tabs-bar) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
:global(.semi-tabs-content) {
|
||||
padding: 20px;
|
||||
height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button, Collapsible, Tabs, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconMinus } from '@douyinfe/semi-icons';
|
||||
import { I18n } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import iconVariable from '../../../assets/icon-variable.png';
|
||||
import { GlobalVariableEditor } from './global-variable-editor';
|
||||
import { FullVariableList } from './full-variable-list';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export function VariablePanel() {
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className={styles['panel-wrapper']}>
|
||||
<Tooltip content={I18n.t('Toggle Variable Panel')}>
|
||||
<Button
|
||||
className={`${styles['variable-panel-button']} ${isOpen ? styles.close : ''}`}
|
||||
theme={isOpen ? 'borderless' : 'light'}
|
||||
onClick={() => setOpen((_open) => !_open)}
|
||||
>
|
||||
{isOpen ? <IconMinus /> : <img src={iconVariable} width={20} height={20} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Collapsible isOpen={isOpen}>
|
||||
<div className={styles['panel-container']}>
|
||||
<Tabs>
|
||||
<Tabs.TabPane itemKey="variables" tab={I18n.t('Variable List')}>
|
||||
<FullVariableList />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane itemKey="global" tab={I18n.t('Global Editor')}>
|
||||
<GlobalVariableEditor />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createVariablePanelPlugin } from './variable-panel-plugin';
|
||||
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { domUtils, injectable, Layer } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { VariablePanel } from './components/variable-panel';
|
||||
|
||||
@injectable()
|
||||
export class VariablePanelLayer extends Layer {
|
||||
onReady(): void {
|
||||
// Fix variable panel in the right of canvas
|
||||
this.config.onDataChange(() => {
|
||||
const { scrollX, scrollY } = this.config.config;
|
||||
domUtils.setStyle(this.node, {
|
||||
position: 'absolute',
|
||||
right: 25 - scrollX,
|
||||
top: scrollY + 25,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
return <VariablePanel />;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ASTFactory, definePluginCreator, GlobalScope, I18n } from '@flowgram.ai/free-layout-editor';
|
||||
import { JsonSchemaUtils } from '@flowgram.ai/form-materials';
|
||||
|
||||
import iconVariable from '../../assets/icon-variable.png';
|
||||
import { VariablePanelLayer } from './variable-panel-layer';
|
||||
|
||||
const fetchMockVariableFromRemote = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string' },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createVariablePanelPlugin = definePluginCreator({
|
||||
onInit(ctx) {
|
||||
ctx.playground.registerLayer(VariablePanelLayer);
|
||||
|
||||
// Fetch Global Variable
|
||||
fetchMockVariableFromRemote().then((v) => {
|
||||
ctx.get(GlobalScope).setVar(
|
||||
ASTFactory.createVariableDeclaration({
|
||||
key: 'global',
|
||||
meta: {
|
||||
title: I18n.t('Global'),
|
||||
icon: iconVariable,
|
||||
},
|
||||
type: JsonSchemaUtils.schemaToAST(v),
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user