chore: import clean Agent Desk snapshot (1/7)

This commit is contained in:
2026-10-16 11:55:22 +00:00
parent 24f6423407
commit 79176541c7
323 changed files with 34882 additions and 2 deletions

View File

@@ -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;
}
}

View File

@@ -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);
},
});

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createContextMenuPlugin } from './context-menu-plugin';

View File

@@ -0,0 +1,9 @@
/**
* 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';
export { createPanelManagerPlugin } from './panel-manager-plugin';

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export enum PanelType {
NodeFormPanel = 'nodeFormPanel',
TestRunFormPanel = 'testRunFormPanel',
ProblemPanel = 'problemPanel',
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
import type { NodeFormPanelProps } from '../../components/sidebar/node-form-panel';
import { PanelType } from './constants';
export const useNodeFormPanel = () => {
const panelManager = usePanelManager();
const open = (props: NodeFormPanelProps) => {
panelManager.open(PanelType.NodeFormPanel, 'right', {
props: props,
});
};
const close = () => panelManager.close(PanelType.NodeFormPanel);
return { open, close };
};
export const useTestRunFormPanel = () => {
const panelManager = usePanelManager();
const open = () => {
panelManager.open(PanelType.TestRunFormPanel, 'docked-right');
};
const close = () => panelManager.close(PanelType.TestRunFormPanel);
return { open, close };
};
export const useProblemPanel = () => {
const panelManager = usePanelManager();
const open = () => {
panelManager.open(PanelType.ProblemPanel, 'bottom');
};
const close = () => panelManager.close(PanelType.ProblemPanel);
return { open, close };
};

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
createPanelManagerPlugin as create,
PanelFactory,
} from '@flowgram.ai/panel-manager-plugin';
import { DemoTools } from '../../components/tools';
import {
TestRunSidePanel,
TestRunSidePanelProps,
} from '../../components/testrun/testrun-panel/test-run-panel';
import { NodeFormPanel, NodeFormPanelProps } from '../../components/sidebar/node-form-panel';
import { ProblemPanel } from '../../components/problem-panel/problem-panel';
import { PanelType } from './constants';
const nodeFormPanelFactory: PanelFactory<NodeFormPanelProps> = {
key: PanelType.NodeFormPanel,
defaultSize: 500,
maxSize: 800,
minSize: 300,
render: (props: NodeFormPanelProps) => <NodeFormPanel {...props} />,
};
const testRunPanelFactory: PanelFactory<TestRunSidePanelProps> = {
key: PanelType.TestRunFormPanel,
defaultSize: 400,
render: () => <TestRunSidePanel />,
};
const problemPanelFactory: PanelFactory<void> = {
key: PanelType.ProblemPanel,
defaultSize: 200,
render: () => <ProblemPanel />,
};
export const createPanelManagerPlugin = () =>
create({
factories: [nodeFormPanelFactory, testRunPanelFactory, problemPanelFactory],
layerProps: {
children: <DemoTools />,
},
});

View File

@@ -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];
}

View File

@@ -0,0 +1,214 @@
import {
WorkflowStatus,
type IReport,
type TaskRunInput,
type WorkflowInputs,
type WorkflowOutputs,
} from '@flowgram.ai/runtime-interface';
type DebugNode = {
id: string;
type: string;
data?: {
config?: Record<string, unknown>;
inputsValues?: Record<string, DebugValue>;
};
};
type DebugEdge = {
sourceNodeID: string;
targetNodeID: string;
};
type DebugSchema = {
nodes?: DebugNode[];
edges?: DebugEdge[];
};
type DebugValue = {
type?: string;
content?: unknown;
};
const BUSINESS_NODE_TYPES = new Set([
'conversation_understanding',
'reply_policy',
'knowledge_retrieve',
'answerability_gate',
'llm_reply',
'analyze_conversation',
'prepare_ticket_draft',
'human_confirm',
'create_ticket',
'handoff_to_human',
'send_reply',
]);
export function isBusinessDebugSchema(input: TaskRunInput): boolean {
const schema = parseSchema(input.schema);
return (schema.nodes ?? []).some((node) => BUSINESS_NODE_TYPES.has(node.type));
}
export function buildBusinessDebugReport(input: TaskRunInput, taskID: string): IReport {
const schema = parseSchema(input.schema);
const nodes = schema.nodes ?? [];
const nodesByID = new Map(nodes.map((node) => [node.id, node]));
const outgoing = new Map<string, DebugEdge[]>();
for (const edge of schema.edges ?? []) {
outgoing.set(edge.sourceNodeID, [...(outgoing.get(edge.sourceNodeID) ?? []), edge]);
}
const values = new Map<string, WorkflowOutputs>();
const reports: IReport['reports'] = {};
const startedAt = Date.now();
let current = nodes.find((node) => node.type === 'start');
let workflowOutputs: WorkflowOutputs = { status: 'completed', debug: true };
for (let step = 0; current && step < 128; step += 1) {
const nodeInputs = resolveNodeInputs(current, values);
const nodeOutputs = simulateNode(current, nodeInputs, input.inputs);
values.set(current.id, nodeOutputs);
const now = Date.now();
reports[current.id] = {
id: current.id,
status: WorkflowStatus.Succeeded,
terminated: true,
startTime: now,
endTime: now,
timeCost: 0,
snapshots: [
{
id: `${taskID}-${current.id}`,
nodeID: current.id,
inputs: nodeInputs,
outputs: nodeOutputs,
data: { debug: true },
},
],
};
if (current.type === 'end') {
workflowOutputs = { ...nodeOutputs, status: 'completed', debug: true };
break;
}
const nextEdge = (outgoing.get(current.id) ?? [])[0];
current = nextEdge ? nodesByID.get(nextEdge.targetNodeID) : undefined;
}
const endedAt = Date.now();
return {
id: taskID,
inputs: input.inputs,
outputs: workflowOutputs,
workflowStatus: {
status: WorkflowStatus.Succeeded,
terminated: true,
startTime: startedAt,
endTime: endedAt,
timeCost: endedAt - startedAt,
},
reports,
messages: {
log: [],
info: [],
debug: [],
error: [],
warning: [],
},
};
}
function parseSchema(raw: string): DebugSchema {
try {
return JSON.parse(raw) as DebugSchema;
} catch {
return {};
}
}
function resolveNodeInputs(
node: DebugNode,
values: Map<string, WorkflowOutputs>
): WorkflowInputs {
return Object.fromEntries(
Object.entries(node.data?.inputsValues ?? {}).map(([name, value]) => [
name,
resolveValue(value, values),
])
);
}
function resolveValue(value: DebugValue, values: Map<string, WorkflowOutputs>): unknown {
if (value?.type === 'ref' && Array.isArray(value.content)) {
const [nodeID, field] = value.content;
if (typeof nodeID === 'string' && typeof field === 'string') {
return values.get(nodeID)?.[field];
}
}
return value?.content;
}
function simulateNode(
node: DebugNode,
inputs: WorkflowInputs,
workflowInputs: WorkflowInputs
): WorkflowOutputs {
const userMessage = String(workflowInputs.userMessage ?? workflowInputs.query ?? '测试消息');
switch (node.type) {
case 'start':
return { ...workflowInputs, userMessage, query: userMessage };
case 'conversation_understanding':
return {
normalizedMessage: userMessage,
messageIntent: 'ticket_request',
answerScope: 'needs_ticket',
confidence: 1,
riskSignals: [],
reason: '调试模拟结果',
};
case 'reply_policy':
return { action: 'prepare_ticket', requiresFlow: true, targetFlow: 'prepare_ticket' };
case 'knowledge_retrieve':
return { documents: [], count: 0, query: String(inputs.query ?? userMessage) };
case 'answerability_gate':
return { answerability: 'answerable', reason: '调试模拟结果' };
case 'analyze_conversation':
return { intent: 'ticket_request', riskLevel: 'low', needTicket: true };
case 'prepare_ticket_draft': {
const issue = String(inputs.issue ?? userMessage).trim() || '测试问题';
const title = issue.length > 30 ? `${issue.slice(0, 30)}…` : issue;
const ticketDraft = {
ready: true,
title,
description: issue,
missingFields: [],
followUpQuestions: [],
conversationFacts: [issue],
};
return { ticketDraft, ...ticketDraft };
}
case 'human_confirm':
return { confirmed: true, responseText: '调试运行自动确认' };
case 'create_ticket':
return {
ticketId: 0,
ticketNo: '',
created: false,
skipped: true,
message: '调试运行不会创建工单。',
};
case 'handoff_to_human':
return { handoffId: 0, skipped: true, message: '调试运行不会转人工。' };
case 'llm_reply': {
const staticReply = String(node.data?.config?.staticReply ?? '').trim();
return { replyText: staticReply || '调试回复' };
}
case 'send_reply':
return { sent: Boolean(inputs.replyText), replyMessageId: 0 };
case 'condition':
return { matched: true };
case 'end':
return { ...inputs, status: 'completed' };
default:
return { ...inputs, debug: true };
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable no-console */
import {
FlowGramAPIName,
IRuntimeClient,
type TaskReportOutput,
} from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
import { buildBusinessDebugReport, isBusinessDebugSchema } from './business-debug-runtime';
@injectable()
export class WorkflowRuntimeBrowserClient implements IRuntimeClient {
private businessDebugTasks = new Map<string, TaskReportOutput>();
constructor() {}
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = async (input) => {
if (isBusinessDebugSchema(input)) {
const taskID = `business-debug-${Date.now()}`;
this.businessDebugTasks.set(taskID, buildBusinessDebugReport(input, taskID));
return { taskID };
}
const { TaskRunAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskRunAPI(input);
};
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = async (
input
) => {
if (this.businessDebugTasks.has(input.taskID)) {
return this.businessDebugTasks.get(input.taskID);
}
const { TaskReportAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskReportAPI(input);
};
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = async (
input
) => {
const report = this.businessDebugTasks.get(input.taskID);
if (report) {
return report.outputs;
}
const { TaskResultAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskResultAPI(input);
};
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = async (
input
) => {
if (this.businessDebugTasks.delete(input.taskID)) {
return { success: true };
}
const { TaskCancelAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskCancelAPI(input);
};
public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate] = async (
input
) => {
if (isBusinessDebugSchema(input)) {
return { valid: true, errors: [] };
}
const { TaskValidateAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载
return TaskValidateAPI(input);
};
}

View File

@@ -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';

View File

@@ -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',
};

View File

@@ -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}`;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface ServerError {
code: string;
message: string;
}

View File

@@ -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);
}
},
});

View 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';

View File

@@ -0,0 +1,221 @@
/**
* 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,
Emitter,
} from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeClient } from '../client';
import { GetGlobalVariableSchema } from '../../variable-panel-plugin';
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;
@inject(GetGlobalVariableSchema) getGlobalVariableSchema: GetGlobalVariableSchema;
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.lines.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(),
globalVariable: this.getGlobalVariableSchema(),
};
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) => node.form);
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);
}
});
}
}

View 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;
}

View File

@@ -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} />;
}

View File

@@ -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 null;
}
const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };
return (
<JsonSchemaEditor
value={value}
onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}
/>
);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,45 @@
/**
* 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 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="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="Variable List">
<FullVariableList />
</Tabs.TabPane>
<Tabs.TabPane itemKey="global" tab="Global Editor">
<GlobalVariableEditor />
</Tabs.TabPane>
</Tabs>
</div>
</Collapsible>
</div>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createVariablePanelPlugin, GetGlobalVariableSchema } from './variable-panel-plugin';

View File

@@ -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 />;
}
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
ASTFactory,
definePluginCreator,
GlobalScope,
VariableDeclaration,
} from '@flowgram.ai/free-layout-editor';
import { IJsonSchema, 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 type GetGlobalVariableSchema = () => IJsonSchema;
export const GetGlobalVariableSchema = Symbol('GlobalVariableSchemaGetter');
export const createVariablePanelPlugin = definePluginCreator<{ initialData?: IJsonSchema }>({
onBind({ bind }) {
bind(GetGlobalVariableSchema).toDynamicValue((ctx) => () => {
const variable = ctx.container.get(GlobalScope).getVar() as VariableDeclaration;
return JsonSchemaUtils.astToSchema(variable?.type);
});
},
onInit(ctx, opts) {
ctx.playground.registerLayer(VariablePanelLayer);
const globalScope = ctx.get(GlobalScope);
if (opts.initialData) {
globalScope.setVar(
ASTFactory.createVariableDeclaration({
key: 'global',
meta: {
title: 'Global',
icon: iconVariable,
},
type: JsonSchemaUtils.schemaToAST(opts.initialData),
})
);
} else {
// You can also fetch global variable from remote
fetchMockVariableFromRemote().then((v) => {
globalScope.setVar(
ASTFactory.createVariableDeclaration({
key: 'global',
meta: {
title: 'Global',
icon: iconVariable,
},
type: JsonSchemaUtils.schemaToAST(v),
})
);
});
}
},
});