feat: manually mirror opencoze's code from bytedance

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,39 @@
/*
* 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 { DependencyOrigin, NodeType } from '../../typings';
export const colorMap: Record<NodeType, string> = {
[NodeType.WORKFLOW]: 'linear-gradient(#ebf9f0 0%, var(--coz-bg-plus) 100%)',
[NodeType.CHAT_FLOW]: 'linear-gradient(#ebf9f0 0%, var(--coz-bg-plus) 100%)',
[NodeType.PLUGIN]: 'linear-gradient(#fbf2ff 0%, var(--coz-bg-plus) 100%)',
[NodeType.KNOWLEDGE]: 'linear-gradient(#fff5ed 0%, var(--coz-bg-plus) 100%)',
[NodeType.DATABASE]: 'linear-gradient(#fef9eb 0%, var(--coz-bg-plus) 100%)',
};
export const contentMap = {
[NodeType.WORKFLOW]: 'edit_block_api_workflow',
[NodeType.CHAT_FLOW]: 'wf_chatflow_76',
[NodeType.PLUGIN]: 'edit_block_api_plugin',
[NodeType.KNOWLEDGE]: 'datasets_title',
[NodeType.DATABASE]: 'bot_database',
};
export const getFromText = {
[DependencyOrigin.APP]: '',
[DependencyOrigin.LIBRARY]: 'workflow_version_origin_text',
[DependencyOrigin.SHOP]: 'navigation_store',
};

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { CozAvatar } from '@coze-arch/coze-design';
import { NodeType } from '../../typings';
import { ReactComponent as IconWorkflow } from '../../assets/icon-workflow.svg';
import { ReactComponent as IconPlugin } from '../../assets/icon-plugin.svg';
import { ReactComponent as IconKnowledge } from '../../assets/icon-knowledge.svg';
import { ReactComponent as IconDatabase } from '../../assets/icon-database.svg';
import { ReactComponent as IconChatflow } from '../../assets/icon-chatflow.svg';
export const Icon = ({ type, icon }: { type: NodeType; icon?: string }) => {
const [error, setError] = useState(false);
if (icon && !error) {
return (
<CozAvatar
size="small"
type="bot"
src={icon}
onError={() => setError(true)}
/>
);
}
if (type === NodeType.CHAT_FLOW) {
return <IconChatflow />;
}
if (type === NodeType.WORKFLOW) {
return <IconWorkflow />;
}
if (type === NodeType.KNOWLEDGE) {
return <IconKnowledge />;
}
if (type === NodeType.DATABASE) {
return <IconDatabase />;
}
// 插件来自商店和资源库场景默认图标不同
if (type === NodeType.PLUGIN) {
return <IconPlugin />;
}
return null;
};

View File

@@ -0,0 +1,83 @@
.node-container {
width: 200px;
height: 80px;
padding: 12px;
background: #fff;
border-radius: 8px;
outline: 1.6px solid var(--coz-stroke-primary);
.header {
display: flex;
column-gap: 8px;
align-items: center;
justify-content: space-between;
height: 24px;
.icon-container {
flex-shrink: 0;
height: 100%;
}
.title {
flex-grow: 1;
width: 100%;
max-width: 144px;
font-size: 16px;
font-weight: 500;
line-height: 22px;
color: var(--coz-fg-primary);
}
}
}
.diff-tag {
position: absolute;
top: -24px;
left: 0;
height: 16px;
padding: 0 3px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
background-color: rgba(var(--coze-bg-5),var(--coze-bg-5-alpha));
border: 1px solid var(--coz-stroke-plus);
border-radius: 3px;
}
.node-container:hover {
cursor: pointer;
outline: 1.6px solid var(--coz-stroke-hglt);
box-shadow: 0 0 0 4px rgba(81, 71, 255, 30%);
}
.activated {
box-shadow: 0 0 0 4px rgba(81, 71, 255, 30%);
}
.highlight {
outline: 1.6px solid var(--coz-stroke-hglt);
}
.tag-container {
display: flex;
column-gap: 4px;
margin-top: 12px;
.tag {
padding: 2px 4px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
background-color: rgba(var(--coze-bg-5),var(--coze-bg-5-alpha));
border-radius: 4px;
}
}

View File

@@ -0,0 +1,188 @@
/*
* 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 { useCallback, useEffect, useState, useContext } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { ConfigProvider, Typography } from '@coze-arch/coze-design';
import {
type FlowNodeEntity,
useConfigEntity,
useService,
} from '@flowgram-adapter/fixed-layout-editor';
import { Collapse } from '../collapse';
import { getNodeExtInfo, getTreeIdFromNodeId } from '../../utils';
import { type NodeType, type CustomLine } from '../../typings';
import { CustomHoverService, TreeService } from '../../services';
import { useCustomNodeRender } from '../../hooks';
import { CustomRenderStateConfigEntity } from '../../entities';
import { TreeContext } from '../../contexts';
import { NODE_WIDTH, COLLAPSE_WIDTH, NODE_HEIGHT } from '../../constants';
import { Tags } from './tags';
import { Icon } from './icon';
import { colorMap } from './constants';
import s from './index.module.less';
const { Text } = Typography;
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
const nodeRender = useCustomNodeRender();
const treeService = useService<TreeService>(TreeService);
// schema 信息存储
const extraInfo = nodeRender.getExtInfo();
const [hoverNode, setHoverNode] = useState(false);
const { renderLinkNode } = useContext(TreeContext);
const { edges } = treeService;
const getPopupContainer = useCallback(
() => node.renderData.node || document.body,
[],
);
const [collapseVisible, setCollapseVisible] = useState(false);
const nodeState = useConfigEntity<CustomRenderStateConfigEntity>(
CustomRenderStateConfigEntity,
true,
);
const nodeId = getTreeIdFromNodeId(node.id);
const highlight = nodeState.selectNodes.includes(nodeId);
const activatedResId: string = nodeState.activatedNode
? getNodeExtInfo(nodeState.activatedNode).id
: '';
const activatedVersion = nodeState.activatedNode
? getNodeExtInfo(nodeState.activatedNode).version
: '';
const isOtherVersion = activatedResId === extraInfo.id;
const activated = nodeState.activatedNode?.id === node.id;
const hoverService = useService<CustomHoverService>(CustomHoverService);
useEffect(() => {
const disposable = hoverService.onHoverLine((line?: CustomLine) => {
if (line?.from?.id && node?.id?.includes(line?.from?.id)) {
setCollapseVisible(true);
} else {
setCollapseVisible(false);
}
});
return () => disposable?.dispose();
}, []);
const handleClick = (e: React.MouseEvent) => {
// 1. 取消其他选中的线条
hoverService.backgroundClick();
// 2. 选中节点,重新计算需要选中的线条
hoverService.selectNode(node);
e.stopPropagation();
};
const handleMouseEnter = () => {
setHoverNode(true);
// 判断节点类型
if (
node.flowNodeType !== 'custom' ||
edges.some(edge => edge.from === node.id)
) {
setCollapseVisible(true);
}
};
const handleMouseLeave = () => {
setHoverNode(false);
setCollapseVisible(false);
};
const collapsed =
(node.flowNodeType === 'blockIcon' &&
!node?.children?.length &&
!node.next) ||
edges.find(e => e.from === node.id)?.collapsed;
const { loop } = extraInfo;
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
<div
className={classNames(s.nodeContainer, {
[s.activated]: activated || isOtherVersion,
[s.highlight]: isOtherVersion || highlight,
})}
style={{
background: colorMap[extraInfo.type as NodeType],
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<div className={s.header}>
<div className={s['icon-container']}>
<Icon type={extraInfo.type} icon={extraInfo.icon} />
</div>
<Text
className={s.title}
ellipsis={{
showTooltip: {
type: 'tooltip',
opts: {
theme: 'dark',
},
},
}}
>
{extraInfo.name}
</Text>
{hoverNode ? renderLinkNode?.(extraInfo) || null : null}
</div>
<Tags
type={extraInfo.type}
from={extraInfo.from}
loop={loop}
version={extraInfo.version}
/>
{collapseVisible || collapsed ? (
<div
style={{
position: 'absolute',
left: NODE_WIDTH / 2 - COLLAPSE_WIDTH / 2,
top: NODE_HEIGHT + 2,
rotate: '90deg',
}}
>
<Collapse
node={node}
collapseNode={node}
collapsed={collapsed}
hoverActivated={hoverNode}
/>
</div>
) : null}
{/* 不同版本的 tag */}
{isOtherVersion && !activated ? (
<div className={s['diff-tag']}>
{activatedVersion === extraInfo.version
? I18n.t('reference_graph_tag_different_version_same_resource')
: I18n.t(
'reference_graph_tag_different_version_of_same_resource',
)}
</div>
) : null}
</div>
</ConfigProvider>
);
};

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { IconCozInfinity } from '@coze-arch/coze-design/icons';
import { Tag, Tooltip } from '@coze-arch/coze-design';
import { type DependencyOrigin, type NodeType } from '../../typings';
import { contentMap, getFromText } from './constants';
import s from './index.module.less';
export const Tags = ({
type,
from,
loop,
version,
}: {
type: NodeType;
from: DependencyOrigin;
loop?: boolean;
version?: string;
}) => {
const typeText = contentMap[type] as I18nKeysNoOptionsType;
const fromText = getFromText[from] as I18nKeysNoOptionsType;
return (
<div className={s['tag-container']}>
<Tag className={s.tag} color="primary">
{I18n.t(typeText)}
</Tag>
{fromText ? (
<Tag className={s.tag} color="primary">
{I18n.t(fromText)}
</Tag>
) : null}
{version ? (
<Tag className={s.tag} color="primary">
{version}
</Tag>
) : null}
{loop ? (
<Tooltip content={I18n.t('reference_graph_node_loop_tip')} theme="dark">
<Tag className={s.tag} color="primary">
<IconCozInfinity style={{ fill: 'var(--coz-fg-hglt)' }} />
</Tag>
</Tooltip>
) : null}
</div>
);
};

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function Arrow({ color }: { color: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{
transform: 'rotate(-90deg)',
}}
>
<circle
cx="8"
cy="8"
r="7"
fill="transparent"
stroke={color}
strokeWidth={1.6}
/>
<path
fill={color}
d="M10.8281 9.4715C11.0883 9.21131 11.0885 8.78909 10.8291 8.52804C10.6413 8.33892 10.4536 8.14952 10.266 7.9601C9.66706 7.35551 9.06799 6.75079 8.46068 6.15496C8.20439 5.90352 7.7947 5.90352 7.53841 6.15496C6.93103 6.75085 6.33191 7.35564 5.73291 7.96029C5.5454 8.14957 5.3579 8.33884 5.17017 8.52782C4.91075 8.78895 4.91096 9.21099 5.17124 9.47127C5.43152 9.73155 5.85355 9.73176 6.11383 9.47148L7.99955 7.58576L9.88548 9.4717C10.1457 9.73189 10.5679 9.73169 10.8281 9.4715Z"
/>
<path
fill={color}
d="M0.888672 7.99997C0.888672 4.07261 4.07242 0.888855 7.99978 0.888855C11.9271 0.888855 15.1109 4.07261 15.1109 7.99997C15.1109 11.9273 11.9271 15.1111 7.99978 15.1111C4.07242 15.1111 0.888672 11.9273 0.888672 7.99997ZM13.818 7.99997C13.818 4.78667 11.2131 2.18178 7.99978 2.18178C4.78649 2.18178 2.1816 4.78667 2.1816 7.99997C2.1816 11.2133 4.78649 13.8181 7.99978 13.8181C11.2131 13.8181 13.818 11.2133 13.818 7.99997Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
.container {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 10px;
color: #fff;
background-color: #f2f3f5;
border-radius: 9px;
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import {
type CollapseProps,
useService,
useBaseColor,
EntityManager,
useRefresh,
} from '@flowgram-adapter/fixed-layout-editor';
import { getTreeIdFromNodeId } from '../../utils';
import {
CustomHoverService,
CustomLinesManager,
TreeService,
} from '../../services';
import { Arrow } from './arrow';
import s from './index.module.less';
export function Collapse(props: Omit<CollapseProps, 'collapsed'>): JSX.Element {
const { collapseNode, hoverActivated } = props;
const hoverService = useService<CustomHoverService>(CustomHoverService);
const treeService = useService<TreeService>(TreeService);
const linesManager = useService<CustomLinesManager>(CustomLinesManager);
const entityManager = useService<EntityManager>(EntityManager);
const refresh = useRefresh();
const treeNode = treeService.getNodeByIdFromTree(
getTreeIdFromNodeId(collapseNode.id),
);
const { edges: originEdges } = treeService;
const edges = treeService.getUnCollapsedEdges();
// 如果没有子元素,就是折叠了。
// 还要判断是否有线条连线
const collapsed =
!collapseNode?.children?.length &&
!collapseNode.next &&
!edges.some(edge => edge.from === collapseNode.id);
const { baseActivatedColor } = useBaseColor();
const rerenderLines = () => {
setTimeout(() => {
linesManager.renderLines();
}, 50);
};
const collapseBlock = (e: React.MouseEvent) => {
e.stopPropagation();
if (treeNode) {
originEdges
.filter(_e => _e.from === treeNode.id && !_e.collapsed)
.forEach(edge => (edge.collapsed = true));
treeNode.children?.forEach(c => {
if (c.type !== 'blockIcon') {
c.data!.collapsed = true;
}
});
}
// 节点重绘
treeService.treeToFlowNodeJson();
// 线条重绘
rerenderLines();
};
const openBlock = (e: React.MouseEvent) => {
e.stopPropagation();
if (treeNode) {
originEdges
.filter(_e => _e.from === treeNode.id && _e.collapsed)
.forEach(edge => (edge.collapsed = false));
treeNode.children?.forEach(c => {
if (c.type !== 'blockIcon') {
c.data!.collapsed = false;
}
});
}
// 节点重绘
treeService.treeToFlowNodeJson();
rerenderLines();
};
// flow-labels-layer 不更新
useEffect(() => {
const disposable = entityManager.onEntityChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
// expand
if (collapsed) {
let childCount = 0;
if (treeNode) {
childCount = treeNode.children?.length || 0;
}
if (originEdges?.length) {
const num = originEdges.reduce((sum, e) => {
if (e.from === collapseNode.id) {
return (sum += 1);
}
return sum;
}, 0);
childCount += num;
}
return (
<div
className={s.container}
onClick={openBlock}
style={{
background: hoverActivated ? '#82A7FC' : '#BBBFC4',
}}
aria-hidden="true"
>
<span
style={{
transform: 'rotate(-90deg)',
}}
>
{childCount}
</span>
</div>
);
}
// dark: var(--semi-color-black)
// light: var(--semi-color-white)
const color = baseActivatedColor;
const handleHover = () => {
hoverService.hoverCollapse(collapseNode);
};
const handleLeave = () => {
hoverService.hoverCollapse(undefined);
};
// collapse
return (
<div
className={s.container}
onClick={collapseBlock}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
aria-hidden="true"
onMouseMove={e => e.stopPropagation()}
>
<Arrow color={color} />
</div>
);
}

View File

@@ -0,0 +1,19 @@
/*
* 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 { BaseNode } from './base-node';
export { Collapse } from './collapse';
export { Tools } from './tools';

View File

@@ -0,0 +1,93 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { nanoid } from 'nanoid';
import {
Rectangle,
useConfigEntity,
} from '@flowgram-adapter/fixed-layout-editor';
import { getLineId } from '../../utils';
import { type CustomLine } from '../../typings';
import { CustomRenderStateConfigEntity } from '../../entities';
import { RenderLine } from './render-line';
export const LinesRenderer = ({
viewBox,
lines,
isViewportVisible,
version,
}: {
viewBox: string;
lines: CustomLine[];
isViewportVisible: (bounds: Rectangle) => boolean;
version: number;
}) => {
// 单线条选中
const [activeLine, setActiveLine] = useState<string[]>([]);
const renderState = useConfigEntity<CustomRenderStateConfigEntity>(
CustomRenderStateConfigEntity,
true,
);
useEffect(() => {
setActiveLine(renderState.activeLines);
}, [renderState.activeLines]);
// semi 弹窗偏移计算有误,这里线条直接全部展示
const visibleLines = lines.filter(line => {
const bounds = Rectangle.createRectangleWithTwoPoints(
line.fromPoint,
line.toPoint,
).pad(10);
return isViewportVisible(bounds);
});
const activatedLines = visibleLines.filter(l =>
activeLine.some(lineId => lineId === getLineId(l)),
);
const normalLines = visibleLines.filter(
l => !activeLine.some(lineId => lineId === getLineId(l)),
);
const allLines = [...normalLines, ...activatedLines];
return (
<svg
className="flow-lines-container"
width="1000"
height="1000"
overflow="visible"
viewBox={viewBox}
xmlns="http://www.w3.org/2000/svg"
// 确保线条数量变化的时候,强制刷新
key={nanoid(5)}
>
{allLines.map(line => {
const activated = activeLine.some(lineId => lineId === getLineId(line));
return (
<RenderLine
key={`${getLineId(line)}${version}`}
line={line}
activated={activated}
/>
);
})}
</svg>
);
};

View File

@@ -0,0 +1,102 @@
/*
* 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 React, { useEffect } from 'react';
import {
useService,
type FlowNodeEntity,
type IPoint,
} from '@flowgram-adapter/fixed-layout-editor';
import { getLineId } from '../../utils';
import { type CustomLine } from '../../typings';
import { CustomHoverService } from '../../services';
import { LINE_CLASS_NAME } from '../../constants/line';
import { STROKE_WIDTH, ARROW_HEIGHT } from '../../constants';
const defaultColor = 'rgba(216, 219, 232, 1)';
const activateColor = '#5147FF';
export const LineSVG = (props: {
line: CustomLine;
hovered: boolean;
path: string;
fromEntity: FlowNodeEntity;
toPos: IPoint;
activated: boolean;
setHovered: (_hovered: boolean) => void;
}) => {
const {
line,
path: bezierPath,
fromEntity,
toPos,
activated,
hovered,
setHovered,
} = props;
const strokeWidth = STROKE_WIDTH;
const customHoverService = useService<CustomHoverService>(CustomHoverService);
useEffect(() => {
const disposable = customHoverService.onHoverCollapse(
(node?: FlowNodeEntity) => {
if (node?.id && node?.id.includes(fromEntity.id)) {
setHovered(true);
} else {
setHovered(false);
}
},
);
const disposableLine = customHoverService.onHoverLine((l?: CustomLine) => {
if (getLineId(l) === getLineId(line)) {
setHovered(true);
} else {
setHovered(false);
}
});
return () => {
disposable.dispose();
disposableLine?.dispose();
};
}, []);
if (fromEntity.collapsed) {
return null;
}
return (
<>
<path
className={LINE_CLASS_NAME}
d={bezierPath}
stroke={activated || hovered ? activateColor : defaultColor}
strokeWidth={strokeWidth}
fill="none"
/>
<polygon
className={LINE_CLASS_NAME}
points={`${toPos.x},${toPos.y} ${toPos.x - 4.5},${toPos.y - ARROW_HEIGHT} ${
toPos.x + 4.5
},${toPos.y - ARROW_HEIGHT}`}
fill={activated || hovered ? activateColor : defaultColor}
/>
</>
);
};

View File

@@ -0,0 +1,82 @@
/*
* 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 React, { useState } from 'react';
import { type IPoint } from '@flowgram-adapter/fixed-layout-editor';
import { type CustomLine } from '../../typings';
import { ARROW_HEIGHT } from '../../constants';
import { getBezierVerticalControlPoints } from './utils';
import { LineSVG } from './line-svg';
export interface PropsType {
line: CustomLine;
activated: boolean;
}
function getPath(params: {
fromPos: IPoint;
toPos: IPoint;
controls: IPoint[];
}): string {
const { fromPos } = params;
const toPos = {
x: params.toPos.x,
y: params.toPos.y - ARROW_HEIGHT,
};
const { controls } = params;
// 渲染端点位置计算
const renderToPos: IPoint = { x: toPos.x, y: toPos.y };
const getPathData = (): string => {
const controlPoints = controls.map(s => `${s.x} ${s.y}`).join(',');
const curveType = controls.length === 1 ? 'S' : 'C';
return `M${fromPos.x} ${fromPos.y} ${curveType} ${controlPoints}, ${renderToPos.x} ${renderToPos.y}`;
};
const path = getPathData();
return path;
}
export function RenderLine(props: PropsType) {
const { activated, line } = props;
const [hovered, setHovered] = useState(false);
const { from } = line;
const controls = getBezierVerticalControlPoints(line.fromPoint, line.toPoint);
const path = getPath({
fromPos: line.fromPoint,
toPos: line.toPoint,
controls,
});
return (
<LineSVG
line={line}
path={path}
fromEntity={from}
toPos={line.toPoint}
activated={activated}
hovered={hovered}
setHovered={setHovered}
/>
);
}

View File

@@ -0,0 +1,108 @@
/*
* 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 IPoint, Rectangle } from '@flowgram-adapter/fixed-layout-editor';
export enum BezierControlType {
RIGHT_TOP,
RIGHT_BOTTOM,
LEFT_TOP,
LEFT_BOTTOM,
}
const CONTROL_MAX = 300;
/**
* 获取贝塞尔曲线垂直方向的控制节点
* @param fromPos 起始点
* @param toPos 终点
*/
export function getBezierVerticalControlPoints(
fromPos: IPoint,
toPos: IPoint,
): IPoint[] {
const rect = Rectangle.createRectangleWithTwoPoints(fromPos, toPos);
let type: BezierControlType;
if (fromPos.y <= toPos.y) {
type =
fromPos.x <= toPos.x
? BezierControlType.RIGHT_BOTTOM
: BezierControlType.LEFT_BOTTOM;
} else {
type =
fromPos.x <= toPos.x
? BezierControlType.RIGHT_TOP
: BezierControlType.LEFT_TOP;
}
let controls: IPoint[];
switch (type) {
case BezierControlType.RIGHT_BOTTOM:
controls = [
{
x: rect.leftTop.x,
y: rect.leftTop.y + rect.height / 2,
},
{
x: rect.rightBottom.x,
y: rect.rightBottom.y - rect.height / 2,
},
];
break;
case BezierControlType.LEFT_BOTTOM:
controls = [
{
x: rect.rightTop.x,
y: rect.rightTop.y + rect.height / 2,
},
{
x: rect.leftBottom.x,
y: rect.leftBottom.y - rect.height / 2,
},
];
break;
case BezierControlType.RIGHT_TOP:
controls = [
{
x: rect.leftBottom.x,
y: rect.leftBottom.y + Math.min(rect.height, CONTROL_MAX),
},
{
x: rect.rightTop.x,
y: rect.rightTop.y - Math.min(rect.height, CONTROL_MAX),
},
];
break;
case BezierControlType.LEFT_TOP:
controls = [
{
x: rect.rightBottom.x,
y: rect.rightBottom.y + Math.min(rect.height, CONTROL_MAX),
},
{
x: rect.leftTop.x,
y: rect.leftTop.y - Math.min(rect.height, CONTROL_MAX),
},
];
break;
default:
controls = [];
}
return controls;
}

View File

@@ -0,0 +1,23 @@
.tools {
position: absolute;
z-index: 999;
bottom: 8px;
left: 50%;
transform: translate(-50%, 0);
display: flex;
column-gap: 8px;
align-items: center;
justify-content: center;
padding: 6px;
background-color: var(--coz-bg-max);
border: 1px solid var(--coz-stroke-primary);
border-radius: 8px;
box-shadow: var(--coz-shadow-default);
.divider {
height: 20px;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { Divider } from '@coze-arch/coze-design';
import { ZoomSelect } from './zoom-select';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
import { Interactive } from './interactive';
import s from './index.module.less';
export const Tools = () => {
const [minimapVisible, setMinimapVisible] = useState(false);
return (
<div className={s.tools}>
<Interactive />
<MinimapSwitch
minimapVisible={minimapVisible}
setMinimapVisible={setMinimapVisible}
/>
<Minimap visible={minimapVisible} />
<Divider className={s.divider} layout="vertical" />
<ZoomSelect />
</div>
);
};

View File

@@ -0,0 +1,126 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/coze-design';
import {
usePlayground,
EditorState,
} from '@flowgram-adapter/fixed-layout-editor';
import {
GuidingPopover,
InteractiveType,
MousePadSelector,
getPreferInteractiveType,
// setPreferInteractiveType,
} from '@coze-common/mouse-pad-selector';
export enum EditorCursorState {
GRAB = 'GRAB',
SELECT = 'SELECT',
}
export const Interactive = () => {
const playground = usePlayground();
const [interactiveType, setInteractiveType] = useState<InteractiveType>(
() => getPreferInteractiveType() as InteractiveType,
);
const [showInteractivePanel, setShowInteractivePanel] = useState(false);
function handleUpdateMouseScrollDelta(
delta: number | ((zoom: number) => number),
) {
playground.config.updateConfig({
mouseScrollDelta: delta,
});
}
const mousePadTooltip = I18n.t(
interactiveType === InteractiveType.Mouse
? 'workflow_mouse_friendly'
: 'workflow_pad_friendly',
);
function handleUpdateInteractiveType(interType: InteractiveType) {
if (interType === InteractiveType.Mouse) {
// 鼠标优先交互模式:更新状态 & 设置小手
playground.editorState.changeState(
EditorState.STATE_MOUSE_FRIENDLY_SELECT.id,
);
} else if (interType === InteractiveType.Pad) {
// 触控板优先交互模式:更新状态 & 设置箭头
playground.editorState.changeState(EditorState.STATE_SELECT.id);
}
setInteractiveType(interType);
return;
}
useEffect(() => {
handleUpdateMouseScrollDelta(zoom => zoom / 20);
// 从缓存读取交互模式,应用生效
const preferInteractiveType = getPreferInteractiveType();
handleUpdateInteractiveType(preferInteractiveType as InteractiveType);
// eslint-disable-next-line react-hooks/exhaustive-deps -- init
}, []);
return (
<GuidingPopover>
<Tooltip
content={mousePadTooltip}
style={{ display: showInteractivePanel ? 'none' : 'block' }}
>
<div className="workflow-toolbar-interactive">
<MousePadSelector
value={interactiveType}
onChange={value => {
setInteractiveType(value);
// 目前逻辑是,只从画布读取设置。
// setPreferInteractiveType(value);
handleUpdateInteractiveType(value as unknown as InteractiveType);
}}
onPopupVisibleChange={setShowInteractivePanel}
containerStyle={{
border: 'none',
height: '24px',
width: '38px',
justifyContent: 'center',
alignItems: 'center',
gap: '2px',
padding: '4px',
paddingTop: '1px',
borderRadius: 'var(--small, 6px)',
}}
iconStyle={{
margin: '0',
width: '16px',
height: '16px',
}}
arrowStyle={{
width: '12px',
height: '12px',
lineHeight: 0,
}}
/>
</div>
</Tooltip>
</GuidingPopover>
);
};

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozRectangleMap } from '@coze-arch/coze-design/icons';
import { Tooltip, IconButton } from '@coze-arch/coze-design';
export const MinimapSwitch = (props: {
minimapVisible: boolean;
setMinimapVisible: (visible: boolean) => void;
}) => {
const { minimapVisible, setMinimapVisible } = props;
return (
<Tooltip content={I18n.t('workflow_toolbar_minimap_tooltips')}>
<IconButton
icon={
<IconCozRectangleMap
className={minimapVisible ? undefined : 'coz-fg-primary'}
/>
}
color={minimapVisible ? 'highlight' : 'secondary'}
onClick={() => setMinimapVisible(!minimapVisible)}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FlowMinimapService,
MinimapRender,
} from '@flowgram-adapter/free-layout-editor';
import { useService } from '@flowgram-adapter/fixed-layout-editor';
export const Minimap = ({ visible }: { visible: boolean }) => {
const minimapService = useService(FlowMinimapService);
if (!visible) {
return <></>;
}
return (
<div
style={{
position: 'absolute',
bottom: '60px',
width: '198px',
zIndex: 99,
}}
>
<MinimapRender
service={minimapService}
panelStyles={{}}
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,136 @@
/*
* 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-magic-numbers */
import { useMemo, useState, type CSSProperties } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowDown } from '@coze-arch/coze-design/icons';
import { Dropdown, Divider } from '@coze-arch/coze-design';
import {
usePlaygroundTools,
usePlayground,
} from '@flowgram-adapter/fixed-layout-editor';
export const ZoomSelect = () => {
const tools = usePlaygroundTools();
const playground = usePlayground();
const [selected, setSelected] = useState(false);
const zoom = useMemo(() => {
const zoomValue = tools.zoom * 100;
return zoomValue.toFixed(0);
}, [tools.zoom]);
// 为了覆盖 coze design 的样式,不能用 tailwind css
const zoomOptionStyle: CSSProperties = {
padding: '8px',
lineHeight: '16px',
};
return (
<Dropdown
clickToHide
position="top"
trigger="custom"
visible={selected}
onClickOutSide={() => setSelected(false)}
onVisibleChange={setSelected}
render={
<Dropdown.Menu>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.out"
style={zoomOptionStyle}
onClick={() => tools.zoomout()}
>
{I18n.t('workflow_toolbar_zoom_out')}
</Dropdown.Item>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.in"
style={zoomOptionStyle}
onClick={() => tools.zoomin()}
>
{I18n.t('workflow_toolbar_zoom_in')}
</Dropdown.Item>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.fit"
style={zoomOptionStyle}
onClick={() => tools.fitView()}
>
{I18n.t('workflow_toolbar_zoom_fit')}
</Dropdown.Item>
<Divider className="m-[4px] w-[108px]" />
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.50"
style={zoomOptionStyle}
onClick={() => {
playground.config.updateZoom(0.5);
}}
>
{I18n.t('workflow_toolbar_zoom_to')} 50%
</Dropdown.Item>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.100"
style={zoomOptionStyle}
onClick={() => {
playground.config.updateZoom(1);
}}
>
{I18n.t('workflow_toolbar_zoom_to')} 100%
</Dropdown.Item>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.150"
style={zoomOptionStyle}
onClick={() => {
playground.config.updateZoom(1.5);
}}
>
{I18n.t('workflow_toolbar_zoom_to')} 150%
</Dropdown.Item>
<Dropdown.Item
data-testid="workflow.detail.toolbar.zoom.200"
style={zoomOptionStyle}
onClick={() => {
playground.config.updateZoom(2);
}}
>
{I18n.t('workflow_toolbar_zoom_to')} 200%
</Dropdown.Item>
</Dropdown.Menu>
}
>
<div
className="workflow-toolbar-zoom flex justify-start items-center w-[70px] h-[24px] p-[2px] rounded-small border border-solid border-[var(--coz-stroke-plus)] cursor-pointer select-none"
data-testid="workflow.detail.toolbar.zoom"
onClick={() => setSelected(!selected)}
style={{
borderColor: selected
? 'var(--coz-stroke-hglt)'
: 'var(--coz-stroke-plus)',
}}
>
<p className="text-[12px] flex items-center mx-[4px] w-[40px] h-[20px]">
{zoom}%
</p>
<div
className="flex items-center justify-center pr-[2px] color-[var(--coz-fg-secondary)]"
style={{
color: 'var(--coz-fg-secondary)',
}}
>
<IconCozArrowDown />
</div>
</div>
</Dropdown>
);
};

View File

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

View File

@@ -0,0 +1,24 @@
/*
* 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 { NODE_WIDTH, NODE_HEIGHT, COLLAPSE_WIDTH } from './node';
export {
STROKE_WIDTH,
ARROW_HEIGHT,
LINE_CLASS_NAME,
LINE_HOVER_DISTANCE,
} from './line';
export { NANO_ID_NUM } from './id';

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const STROKE_WIDTH = 2;
export const ARROW_HEIGHT = 7;
export const LINE_CLASS_NAME = 'resource-tree-custom-line';
export const LINE_HOVER_DISTANCE = 8; // 线条 hover 的最小检测距离

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
/*
* 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 {
ConfigEntity,
type FlowNodeEntity,
type EntityOpts,
} from '@flowgram-adapter/fixed-layout-editor';
interface NodeRenderState {
selectNodes: string[];
selectLines: string[];
activatedNode?: FlowNodeEntity;
}
/**
* 渲染相关的全局状态管理
*/
export class CustomRenderStateConfigEntity extends ConfigEntity<
NodeRenderState,
EntityOpts
> {
static type = 'CustomRenderStateConfigEntity';
getDefaultConfig() {
return {
selectNodes: [],
selectLines: [],
};
}
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(conf: EntityOpts) {
super(conf);
}
get selectNodes() {
return this.config.selectNodes;
}
setSelectNodes(nodes: string[]) {
this.updateConfig({
selectNodes: nodes,
});
}
get activatedNode() {
return this.config.activatedNode;
}
setActivatedNode(node?: FlowNodeEntity) {
this.updateConfig({
activatedNode: node,
});
}
get activeLines() {
return this.config.selectLines;
}
set activeLines(lines) {
this.updateConfig({
selectLines: lines,
});
}
}

View File

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

View File

@@ -0,0 +1,49 @@
/*
* 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 { ConfigEntity } from '@flowgram-adapter/fixed-layout-editor';
interface CustomRenderState {
version: number;
}
/**
* 渲染相关的全局状态管理
*/
export class CustomRenderStateEntity extends ConfigEntity<CustomRenderState> {
static type = 'CustomRenderStateEntity';
private _localVersion = 0;
getDefaultConfig() {
return {
version: 0,
};
}
private bumpVersion() {
this._localVersion = this._localVersion + 1;
if (this._localVersion === Number.MAX_SAFE_INTEGER) {
this._localVersion = 0;
}
}
updateVersion() {
this.bumpVersion();
this.updateConfig({
version: this._localVersion,
});
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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 { useMemo, useCallback, forwardRef } from 'react';
import { type interfaces } from 'inversify';
import {
FlowDocument,
createPluginContextDefault,
PlaygroundReactProvider,
} from '@flowgram-adapter/fixed-layout-editor';
import {
createFixedLayoutPreset,
type FixedLayoutPluginContext,
type FixedLayoutProps,
} from './preset';
export const FixedLayoutEditorProvider = forwardRef<
FixedLayoutPluginContext,
FixedLayoutProps
>(function FixedLayoutEditorProvider(props: FixedLayoutProps, ref) {
const { parentContainer, children, ...others } = props;
const preset = useMemo(() => createFixedLayoutPreset(others), []);
const customPluginContext = useCallback(
(container: interfaces.Container) =>
({
...createPluginContextDefault(container),
get document(): FlowDocument {
return container.get<FlowDocument>(FlowDocument);
},
}) as FixedLayoutPluginContext,
[],
);
return (
<PlaygroundReactProvider
ref={ref}
plugins={preset}
customPluginContext={customPluginContext}
parentContainer={parentContainer}
>
{children}
</PlaygroundReactProvider>
);
});

View File

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

View File

@@ -0,0 +1,88 @@
/*
* 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 {
createOperationPlugin,
type PluginsProvider,
createDefaultPreset,
createPlaygroundPlugin,
type Plugin,
FlowDocumentOptionsDefault,
FlowDocumentOptions,
FlowNodesContentLayer,
FlowNodesTransformLayer,
FlowScrollBarLayer,
FlowScrollLimitLayer,
createPlaygroundReactPreset,
} from '@flowgram-adapter/fixed-layout-editor';
import {
type FixedLayoutPluginContext,
type FixedLayoutProps,
DEFAULT,
} from './fixed-layout-props';
import { FixedLayoutContainerModule } from './container-module';
export function createFixedLayoutPreset(
opts: FixedLayoutProps,
): PluginsProvider<FixedLayoutPluginContext> {
return (ctx: FixedLayoutPluginContext) => {
opts = { ...DEFAULT, ...opts };
let plugins: Plugin[] = [createOperationPlugin(opts)];
/**
* 加载默认编辑器配置
*/
plugins = createDefaultPreset(opts, plugins)(ctx);
/*
* 加载固定布局画布模块
* */
plugins.push(
createPlaygroundPlugin<FixedLayoutPluginContext>({
containerModules: [FixedLayoutContainerModule],
onBind(bindConfig) {
if (!bindConfig.isBound(FlowDocumentOptions)) {
bindConfig.bind(FlowDocumentOptions).toConstantValue({
...FlowDocumentOptionsDefault,
jsonAsV2: true,
defaultLayout: opts.defaultLayout,
toNodeJSON: opts.toNodeJSON,
fromNodeJSON: opts.fromNodeJSON,
allNodesDefaultExpanded: opts.allNodesDefaultExpanded,
} as FlowDocumentOptions);
}
},
onInit: _ctx => {
_ctx.playground.registerLayers(
FlowNodesContentLayer, // 节点内容渲染
FlowNodesTransformLayer, // 节点位置偏移计算
);
if (!opts.scroll?.disableScrollLimit) {
// 控制滚动范围
_ctx.playground.registerLayer(FlowScrollLimitLayer);
}
if (!opts.scroll?.disableScrollBar) {
// 控制条
_ctx.playground.registerLayer(FlowScrollBarLayer);
}
if (opts.nodeRegistries) {
_ctx.document.registerFlowNodes(...opts.nodeRegistries);
}
},
}),
);
return createPlaygroundReactPreset(opts, plugins)(ctx);
};
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type ClipboardService,
type EditorPluginContext,
EditorProps,
type FlowDocument,
type FlowDocumentJSON,
type FlowLayoutDefault,
type FlowOperationService,
type SelectionService,
type FixedHistoryPluginOptions,
type HistoryService,
} from '@flowgram-adapter/fixed-layout-editor';
export interface FixedLayoutPluginContext extends EditorPluginContext {
document: FlowDocument;
/**
* 提供对画布节点相关操作方法, 并 支持 redo/undo
*/
operation: FlowOperationService;
clipboard: ClipboardService;
selection: SelectionService;
history: HistoryService;
}
/**
* 固定布局配置
*/
export interface FixedLayoutProps
extends EditorProps<FixedLayoutPluginContext, FlowDocumentJSON> {
history?: FixedHistoryPluginOptions<FixedLayoutPluginContext> & {
disableShortcuts?: boolean;
};
defaultLayout?: FlowLayoutDefault | string; // 默认布局
}
export const DEFAULT: FixedLayoutProps =
EditorProps.DEFAULT as FixedLayoutProps;

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { injectable } from 'inversify';
import {
FlowNodesContentLayer,
FlowNodesTransformLayer,
type FlowRendererContribution,
type FlowRendererRegistry,
FlowScrollBarLayer,
FlowScrollLimitLayer,
type FlowDocument,
type FlowDocumentContribution,
FlowNodeRenderData,
FlowNodeTransformData,
FlowNodeTransitionData,
PlaygroundLayer,
FixedLayoutRegistries,
} from '@flowgram-adapter/fixed-layout-editor';
import { FlowLinesLayer } from '../../layers';
@injectable()
export class FlowRegisters
implements FlowDocumentContribution, FlowRendererContribution
{
/**
* 注册数据层
* @param document
*/
registerDocument(document: FlowDocument) {
/**
* 注册节点 (ECS - Entity)
*/
document.registerFlowNodes(
// 等待简化
FixedLayoutRegistries.RootRegistry, // 根节点
FixedLayoutRegistries.StartRegistry, // 开始节点
FixedLayoutRegistries.DynamicSplitRegistry, // 动态分支(并行、排他)
FixedLayoutRegistries.BlockRegistry, // 单条 block 注册
FixedLayoutRegistries.InlineBlocksRegistry, // 多个 block 组成的 block 列表
FixedLayoutRegistries.BlockIconRegistry, // icon 节点,如条件分支的菱形图标
// FixedLayoutRegistries.EndRegistry, // 结束节点
FixedLayoutRegistries.EmptyRegistry, // 占位节点
);
/**
* 注册节点数据 (ECS - Component)
*/
document.registerNodeDatas(
FlowNodeRenderData, // 渲染节点相关数据
FlowNodeTransitionData, // 线条绘制数据
FlowNodeTransformData, // 坐标计算数据
);
}
/**
* 注册渲染层
* @param renderer
*/
registerRenderer(renderer: FlowRendererRegistry) {
/**
* 注册 layer (ECS - System)
*/
renderer.registerLayers(
FlowNodesTransformLayer, // 节点位置渲染
FlowNodesContentLayer, // 节点内容渲染
FlowLinesLayer, // 线条渲染
// FlowLabelsLayer, // Label 渲染
PlaygroundLayer, // 画布基础层,提供缩放、手势等能力
FlowScrollLimitLayer, // 控制滚动范围
FlowScrollBarLayer, // 滚动条
);
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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 {
type FixedLayoutPluginContext,
type FixedLayoutProps,
} from './fixed-layout-props';
export { createFixedLayoutPreset } from './fixed-layout-preset';

View File

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

View File

@@ -0,0 +1,19 @@
/*
* 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 { useEditorProps } from './use-editor-props';
export { useBaseColor } from './use-base-color';
export { useCustomNodeRender } from './use-custom-node-render';

View File

@@ -0,0 +1,45 @@
/*
* 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 {
ConstantKeys,
FlowDocumentOptions,
useService,
} from '@flowgram-adapter/fixed-layout-editor';
export const BASE_DEFAULT_COLOR = '#BBBFC4';
export const BASE_DEFAULT_ACTIVATED_COLOR = '#5147ff';
export function useBaseColor(): {
baseColor: string;
baseActivatedColor: string;
} {
const options = useService<FlowDocumentOptions>(FlowDocumentOptions);
return {
baseColor:
options.constants?.[ConstantKeys.BASE_COLOR] || BASE_DEFAULT_COLOR,
baseActivatedColor:
options.constants?.[ConstantKeys.BASE_ACTIVATED_COLOR] ||
BASE_DEFAULT_ACTIVATED_COLOR,
};
}
export const DEFAULT_LINE_ATTRS: React.SVGProps<SVGPathElement> = {
stroke: BASE_DEFAULT_COLOR,
fill: 'transparent',
strokeLinecap: 'round',
strokeLinejoin: 'round',
};

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useContext, useMemo } from 'react';
import {
usePlayground,
type FlowNodeEntity,
FlowNodeRenderData,
PlaygroundEntityContext,
type NodeFormProps,
getNodeForm,
} from '@flowgram-adapter/fixed-layout-editor';
import { useObserve } from '@flowgram-adapter/common';
import { getStoreNode } from '../utils';
export interface NodeRenderReturnType {
/**
* 当前节点 (如果是 icon 则会返回它的父节点)
*/
node: FlowNodeEntity;
/**
* 节点是否激活
*/
activated: boolean;
/**
* 节点是否展开
*/
expanded: boolean;
/**
* 鼠标进入, 主要用于控制 activated 状态
*/
onMouseEnter: (e: React.MouseEvent) => void;
/**
* 鼠标离开, 主要用于控制 activated 状态
*/
onMouseLeave: (e: React.MouseEvent) => void;
/**
* 渲染表单,只有节点引擎开启才能使用
*/
form: NodeFormProps<any> | undefined;
/**
* 获取节点的扩展数据
*/
getExtInfo<T = any>(): T;
/**
* 更新节点的扩展数据
* @param extInfo
*/
updateExtInfo<T = any>(extInfo: T): void;
/**
* 展开/收起节点
* @param expanded
*/
toggleExpand(): void;
/**
* 全局 readonly 状态
*/
readonly: boolean;
}
/**
* 自定义 useNodeRender
* 不区分 blockIcon 和 inlineBlocks
*/
export function useCustomNodeRender(
nodeFromProps?: FlowNodeEntity,
): NodeRenderReturnType {
const ctx = useContext<FlowNodeEntity>(PlaygroundEntityContext);
const renderNode = nodeFromProps || ctx;
const renderData =
renderNode.getData<FlowNodeRenderData>(FlowNodeRenderData)!;
const { node } = getStoreNode(renderNode);
const { expanded, activated } = renderData;
const playground = usePlayground();
const onMouseEnter = useCallback(() => {
renderData.toggleMouseEnter();
}, [renderData]);
const onMouseLeave = useCallback(() => {
renderData.toggleMouseLeave();
}, [renderData]);
const toggleExpand = useCallback(() => {
renderData.toggleExpand();
}, [renderData]);
const getExtInfo = useCallback(() => node.getExtInfo() as any, [node]);
const updateExtInfo = useCallback(
(data: any) => {
node.updateExtInfo(data);
},
[node],
);
const form = useMemo(() => getNodeForm(node), [node]);
// Listen FormState change
const formState = useObserve<any>(form?.state);
const { readonly } = playground.config;
return useMemo(
() => ({
node,
activated,
readonly,
expanded,
onMouseEnter,
onMouseLeave,
getExtInfo,
updateExtInfo,
toggleExpand,
get form() {
if (!form) {
return undefined;
}
return {
...form,
get values() {
return form.values!;
},
get state() {
return formState;
},
};
},
}),
[
node,
activated,
readonly,
expanded,
onMouseEnter,
onMouseLeave,
getExtInfo,
updateExtInfo,
toggleExpand,
form,
formState,
],
);
}

View File

@@ -0,0 +1,102 @@
/*
* 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 { useMemo } from 'react';
import { createMinimapPlugin } from '@flowgram-adapter/free-layout-editor';
import {
ConstantKeys,
type FixedLayoutProps,
FlowRendererKey,
} from '@flowgram-adapter/fixed-layout-editor';
import { CustomLinesManager, TreeService } from '../services';
import { createCustomLinesPlugin } from '../plugins';
import { Split } from '../node-registries';
import { BaseNode, Collapse } from '../components';
export const useEditorProps = (data?: any, json?: any) =>
useMemo<FixedLayoutProps>(
() => ({
background: true,
readonly: false,
initialData: data as any,
onDispose() {
console.log('---- Playground Dispose ----');
},
nodeRegistries: [Split],
constants: {
[ConstantKeys.BASE_ACTIVATED_COLOR]: '#5147ff',
[ConstantKeys.INLINE_BLOCKS_PADDING_TOP]: 44,
[ConstantKeys.NODE_SPACING]: 64,
},
materials: {
renderNodes: {
[FlowRendererKey.ADDER]: () => null,
[FlowRendererKey.COLLAPSE]: Collapse,
[FlowRendererKey.BRANCH_ADDER]: () => null,
[FlowRendererKey.DRAG_NODE]: () => null,
},
renderDefaultNode: BaseNode, // 节点渲染
},
onReady(ctx) {
const treeService = ctx.get<TreeService>(TreeService);
treeService.transformSchema(json);
treeService.treeToFlowNodeJson();
// 强制 resize 生效
setTimeout(() => {
ctx.playground.resize();
}, 100);
},
onAllLayersRendered(ctx) {
const linesManager = ctx.get<CustomLinesManager>(CustomLinesManager);
linesManager.initLines();
},
plugins: () => [
/**
* 自定义线条插件
*/
createCustomLinesPlugin({}),
/**
* Minimap plugin
* 缩略图插件
*/
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 182,
canvasHeight: 102,
canvasPadding: 50,
canvasBackground: 'rgba(245, 245, 245, 1)',
canvasBorderRadius: 10,
viewportBackground: 'rgba(235, 235, 235, 1)',
viewportBorderRadius: 4,
viewportBorderColor: 'rgba(201, 201, 201, 1)',
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: 'rgba(255, 255, 255, 1)',
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
overlayColor: 'rgba(255, 255, 255, 0)',
},
inactiveDebounceTime: 1,
}),
],
}),
[],
);

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EditorRenderer } from '@flowgram-adapter/fixed-layout-editor';
import { type DependencyTree } from '@coze-arch/bot-api/workflow_api';
import { useEditorProps } from './hooks';
import { FixedLayoutEditorProvider } from './fixed-layout-editor-provider';
import { TreeContext } from './contexts';
import { Tools } from './components';
import '@flowgram-adapter/fixed-layout-editor/css-load';
export { NodeType, DependencyOrigin } from './typings';
export { isDepEmpty } from './utils';
export const ResourceTree = ({
className,
data,
renderLinkNode,
}: {
className?: string;
data: DependencyTree;
renderLinkNode?: (extInfo: any) => React.ReactNode;
}) => {
const initialData = {
nodes: [],
};
const editorProps = useEditorProps(initialData, data);
return (
<FixedLayoutEditorProvider {...editorProps}>
<TreeContext.Provider
value={{
renderLinkNode,
}}
>
<div className={className}>
<EditorRenderer />
<Tools />
</div>
</TreeContext.Provider>
</FixedLayoutEditorProvider>
);
};

View File

@@ -0,0 +1,113 @@
/*
* 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 FlowDocumentJSON } from '@flowgram-adapter/fixed-layout-editor';
// only for test
export const initialData: FlowDocumentJSON = {
nodes: [
// 开始节点
{
id: 'start_0',
type: 'split',
data: {
title: 'Start',
content: 'start content',
},
blocks: [
{
id: 'noop',
type: 'block',
blocks: [
// 分支节点
{
id: 'condition_0',
type: 'split',
data: {
title: 'Condition',
},
blocks: [
{
id: 'block_1',
type: 'block',
blocks: [
{
id: 'block_4',
type: 'split',
blocks: [
{
id: 'block_5',
type: 'block',
blocks: [
{
id: 'custom_1',
type: 'custom',
meta: {
isNodeEnd: true,
},
},
{
id: 'custom_2',
type: 'custom',
meta: {
isNodeEnd: true,
},
},
],
},
],
},
],
},
{
id: 'block_2',
type: 'block',
meta: {
isNodeEnd: true,
},
blocks: [
{
id: 'custom_3',
type: 'custom',
meta: {
isNodeEnd: true,
},
},
],
},
{
id: 'block_3',
type: 'block',
meta: {
isNodeEnd: true,
},
blocks: [
{
id: 'custom_4',
type: 'custom',
meta: {
isNodeEnd: true,
},
},
],
},
],
},
],
},
],
},
],
};

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { throttle } from 'lodash-es';
import { inject, injectable } from 'inversify';
import {
FlowDocument,
FlowDocumentTransformerEntity,
Layer,
observeEntity,
domUtils,
} from '@flowgram-adapter/fixed-layout-editor';
import { CustomHoverService, CustomLinesManager } from '../services';
import { CustomRenderStateEntity } from '../entities';
import { LINE_CLASS_NAME } from '../constants';
import { LinesRenderer } from '../components/lines-render';
@injectable()
export class FlowLinesLayer extends Layer {
@inject(FlowDocument) declare document: FlowDocument;
@inject(CustomLinesManager) declare linesManager: CustomLinesManager;
@inject(CustomHoverService) declare hoverService: CustomHoverService;
node = domUtils.createDivWithClass('gedit-flow-lines-layer');
@observeEntity(FlowDocumentTransformerEntity)
declare documentTransformer: FlowDocumentTransformerEntity;
@observeEntity(CustomRenderStateEntity)
protected declare renderState: CustomRenderStateEntity;
/**
* 可视区域变化
*/
onViewportChange = throttle(() => {
this.render();
}, 100) as ReturnType<typeof throttle>;
onZoom() {
const svgContainer = this.node!.querySelector('svg.flow-lines-container')!;
svgContainer?.setAttribute?.('viewBox', this.viewBox);
}
onReady() {
this.node.style.zIndex = '1';
this.toDispose.pushAll([
this.listenPlaygroundEvent('click', () => {
this.hoverService.backgroundClick();
}),
this.listenPlaygroundEvent('mousemove', (e: React.MouseEvent) => {
const lineDomNodes = this.playgroundNode.querySelectorAll(
`.${LINE_CLASS_NAME}`,
);
const checkTargetFromLine = [...lineDomNodes].some(lineDom =>
lineDom.contains(e.target as HTMLElement),
);
const mousePos = this.config.getPosFromMouseEvent(e);
this.hoverService.updateHoverLine(mousePos, checkTargetFromLine);
}),
]);
}
get viewBox(): string {
const ratio = 1000 / this.config.finalScale;
return `0 0 ${ratio} ${ratio}`;
}
render(): JSX.Element {
const isViewportVisible = this.config.isViewportVisible.bind(this.config);
// 还没初始化
if (this.documentTransformer?.loading) {
return <></>;
}
this.documentTransformer?.refresh?.();
return (
<LinesRenderer
viewBox={this.viewBox}
isViewportVisible={isViewportVisible}
lines={this.linesManager.lines}
version={this.renderState.config.version}
/>
);
}
}

View File

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

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type FlowNodeJSON,
FlowNodeBaseType,
type FlowNodeEntity,
type FlowNodeRegister,
} from '@flowgram-adapter/fixed-layout-editor';
/**
* 无 BlockOrderIcon 的分支节点
*/
export const Split: FlowNodeRegister = {
type: 'split',
extend: 'dynamicSplit',
onBlockChildCreate(
originParent: FlowNodeEntity,
blockData: FlowNodeJSON,
addedNodes: FlowNodeEntity[] = [], // 新创建的节点都要存在这里
) {
const { document } = originParent;
const parent = document.getNode(`$inlineBlocks$${originParent.id}`);
// 块节点会生成一个空的 Block 节点用来切割 Block
const proxyBlock = document.addNode({
id: `$block$${blockData.id}`,
type: FlowNodeBaseType.BLOCK,
originParent,
parent,
});
const realBlock = document.addNode(
{
...blockData,
type: blockData.type || FlowNodeBaseType.BLOCK,
parent: proxyBlock,
},
addedNodes,
);
addedNodes.push(proxyBlock, realBlock);
return proxyBlock;
},
};

View File

@@ -0,0 +1,38 @@
/*
* 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 {
bindConfigEntity,
definePluginCreator,
type PluginCreator,
} from '@flowgram-adapter/fixed-layout-editor';
import {
CustomLinesManager,
CustomHoverService,
TreeService,
} from '../services';
import { CustomRenderStateConfigEntity } from '../entities';
export const createCustomLinesPlugin: PluginCreator<any> =
definePluginCreator<any>({
onBind: ({ bind }) => {
bind(CustomLinesManager).toSelf().inSingletonScope();
bind(CustomHoverService).toSelf().inSingletonScope();
bind(TreeService).toSelf().inSingletonScope();
bindConfigEntity(bind, CustomRenderStateConfigEntity);
},
});

View File

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

View File

@@ -0,0 +1,205 @@
/*
* 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 {
Emitter,
EntityManager,
FlowDocument,
type IPoint,
type PositionSchema,
type FlowNodeEntity,
} from '@flowgram-adapter/fixed-layout-editor';
import {
getLineId,
getNodeIdFromTreeId,
getTreeIdFromNodeId,
calcDistance,
} from '../utils';
import { type CustomLine, type EdgeItem } from '../typings';
import { CustomRenderStateConfigEntity } from '../entities';
import { LINE_HOVER_DISTANCE } from '../constants';
import { TreeService } from './tree-service';
import { CustomLinesManager } from './custom-lines-manager';
@injectable()
export class CustomHoverService {
@inject(CustomLinesManager) declare linesManager: CustomLinesManager;
@inject(FlowDocument) declare document: FlowDocument;
@inject(TreeService) declare treeService: TreeService;
@inject(EntityManager) declare entityManager: EntityManager;
@inject(CustomRenderStateConfigEntity)
declare renderStateEntity: CustomRenderStateConfigEntity;
get edges(): EdgeItem[] {
return this.treeService.getUnCollapsedEdges();
}
private onBackgroundClickEmitter = new Emitter<void>();
onBackgroundClick = this.onBackgroundClickEmitter.event;
private onHoverCollapseEmitter = new Emitter<FlowNodeEntity | undefined>();
onHoverCollapse = this.onHoverCollapseEmitter.event;
private onHoverLineEmitter = new Emitter<CustomLine | undefined>();
onHoverLine = this.onHoverLineEmitter.event;
private onSelectNodeEmitter = new Emitter<void>();
onSelectNode = this.onSelectNodeEmitter.event;
private _hoveredLine: CustomLine | undefined;
get hoveredLine() {
return this._hoveredLine;
}
/**
* 根据鼠标位置计算和线条的间距
*/
getCloseInLineFromMousePos(
mousePos: IPoint,
minDistance: number = LINE_HOVER_DISTANCE,
): CustomLine | undefined {
let targetLine: CustomLine | undefined, targetLineDist: number | undefined;
(this.linesManager?.lines || []).forEach(line => {
const dist = calcDistance(mousePos, line);
if (dist <= minDistance && (!targetLineDist || targetLineDist >= dist)) {
targetLineDist = dist;
targetLine = line;
}
});
return targetLine;
}
updateHoverLine(pos: PositionSchema, checkTarget: boolean) {
if (!checkTarget) {
this.onHoverLineEmitter.fire(undefined);
}
const hoverLine = this.getCloseInLineFromMousePos(pos);
if (hoverLine) {
this.onHoverLineEmitter.fire(hoverLine);
this._hoveredLine = hoverLine;
} else {
this.onHoverLineEmitter.fire(undefined);
this._hoveredLine = undefined;
}
return hoverLine;
}
backgroundClick(updateLines = true) {
this.onBackgroundClickEmitter.fire();
this.renderStateEntity.setSelectNodes([]);
this.renderStateEntity.setActivatedNode(undefined);
if (this.hoveredLine) {
const lineId = getLineId(this._hoveredLine);
this.renderStateEntity.activeLines = [lineId!];
} else if (updateLines) {
this.renderStateEntity.activeLines = [];
} else {
this.renderStateEntity.activeLines = [];
}
}
// 折叠策略:访问所有连线的元素,同时访问其父元素,执行 collapse。
hoverCollapse(from?: FlowNodeEntity) {
this.onHoverCollapseEmitter.fire(from);
}
getParent(node?: FlowNodeEntity): FlowNodeEntity | undefined {
if (!node) {
return node;
}
if (node.flowNodeType !== 'blockIcon') {
return this.getParent(node.parent);
} else {
return node;
}
}
selectNode(node: FlowNodeEntity) {
const selectNodes = this.getRelatedNodes(node);
this.renderStateEntity.setSelectNodes(selectNodes);
this.renderStateEntity.setActivatedNode(node);
// 高亮所有相关的线条
const activeLines: string[] = [];
this.linesManager.lines.map(line => {
const fromInclude = selectNodes.includes(line.from.id);
const toInclude = selectNodes.includes(line.to.id);
if (fromInclude && toInclude) {
activeLines.push(getLineId(line)!);
}
});
this.renderStateEntity.activeLines = activeLines;
this.linesManager.renderLines();
}
traverseAncestors = (node: FlowNodeEntity): string[] => {
let ancArr: string[] = [];
if (node.parent) {
const arr = this.traverseAncestors(node.parent);
ancArr = ancArr.concat(arr);
}
this.edges.forEach(edge => {
if (edge.to === getTreeIdFromNodeId(node.id)) {
// push 额外线条
ancArr.push(edge.from);
// 遍历之前的节点信息
const fromNode = this.document.getNode(getNodeIdFromTreeId(edge.from));
if (fromNode) {
const arr = this.traverseAncestors(fromNode);
ancArr.concat(arr);
}
}
});
ancArr.push(node.id);
return ancArr;
};
traverseDescendants = (node: FlowNodeEntity): string[] => {
let ancArr: string[] = [];
if (node.children?.length) {
node.children?.forEach(child => {
ancArr.push(child.id);
const childArr = this.traverseDescendants(child);
ancArr = ancArr.concat(childArr);
});
}
if (node.next) {
const childArr = this.traverseDescendants(node.next);
ancArr = ancArr.concat(childArr);
}
return ancArr;
};
/**
* 根据当前树结构,遍历节点并且选中
*/
getRelatedNodes = (node: FlowNodeEntity) => {
const parentRelated = this.traverseAncestors(node);
const childRelated = this.traverseDescendants(node);
return [...parentRelated, ...childRelated];
};
}

View File

@@ -0,0 +1,173 @@
/*
* 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 {
EntityManager,
FlowDocument,
type FlowNodeEntity,
FlowNodeTransformData,
type IPoint,
} from '@flowgram-adapter/fixed-layout-editor';
import type { CustomLine, EdgeItem } from '../typings';
import { CustomRenderStateEntity } from '../entities';
import { NODE_HEIGHT } from '../constants';
import { TreeService } from './tree-service';
const FILTER_NODE_TYPE = ['block', 'blockIcon', 'root', 'inlineBlocks'];
@injectable()
export class CustomLinesManager {
@inject(FlowDocument) declare document: FlowDocument;
@inject(EntityManager) declare entityManager: EntityManager;
// 额外的连线
@inject(TreeService) declare treeService: TreeService;
get edges(): EdgeItem[] {
return this.treeService.edges;
}
get treeNodes() {
return this.filterTreeNode(this.document.getAllNodes());
}
private _lines: CustomLine[] = [];
get lines() {
return this._lines;
}
set lines(lines: CustomLine[]) {
this._lines = lines;
}
/**
* 过滤其他的非渲染节点
*/
filterTreeNode(nodes: FlowNodeEntity[]) {
return nodes.filter(
node => !FILTER_NODE_TYPE.includes(node.flowNodeType as string),
);
}
getOutputFromInput(inputPoint: IPoint) {
return {
x: inputPoint.x,
y: inputPoint.y + NODE_HEIGHT,
};
}
bfsAddLine(root: FlowNodeEntity): CustomLine[] {
const queue: FlowNodeEntity[] = [root];
const result: CustomLine[] = [];
while (queue.length > 0) {
const node = queue.shift()!;
if (node.next) {
result.push({
from: node,
to: node.next,
fromPoint: this.getOutputFromInput(
node.getData(FlowNodeTransformData)?.inputPoint,
),
toPoint: node.next.getData(FlowNodeTransformData)?.inputPoint,
});
queue.push(node.next);
}
if (node.children?.length) {
if (node.flowNodeType === 'root') {
queue.push(node.children[0]);
} else if (node.flowNodeType === 'split') {
// 分支逻辑特殊处理
const inlineBlocksChildren = node.children[1]?.children || [];
const branchChildren =
inlineBlocksChildren
?.map(c => c?.children?.[0]?.children?.[0])
?.filter(Boolean) || [];
branchChildren.forEach(child => {
result.push({
from: node,
to: child,
fromPoint: this.getOutputFromInput(
node.getData(FlowNodeTransformData)?.inputPoint,
),
toPoint: child.getData(FlowNodeTransformData)?.inputPoint,
});
});
queue.push(...branchChildren);
} else {
node.children.forEach(child => {
result.push({
from: node,
to: child,
fromPoint: this.getOutputFromInput(
node.getData(FlowNodeTransformData)?.inputPoint,
),
toPoint: child.getData(FlowNodeTransformData)?.inputPoint,
});
});
queue.push(...node.children);
}
}
}
return result;
}
initLines() {
if (this._lines?.length) {
return;
}
this.renderLines();
}
renderLines() {
// 下一帧渲染,保证线条数据最新
requestAnimationFrame(() => {
const lines = this.bfsAddLine(this.document.originTree.root);
const extraLines: CustomLine[] = (this.edges || [])
.filter(edge => {
const from = this.entityManager.getEntityById<FlowNodeEntity>(
edge.from,
)!;
const to = this.entityManager.getEntityById<FlowNodeEntity>(edge.to)!;
return from && to && !edge.collapsed;
})
.map(edge => {
const from = this.entityManager.getEntityById<FlowNodeEntity>(
edge.from,
)!;
const to = this.entityManager.getEntityById<FlowNodeEntity>(edge.to)!;
return {
from,
to,
fromPoint: this.getOutputFromInput(
from.getData(FlowNodeTransformData)?.inputPoint,
),
toPoint: to.getData(FlowNodeTransformData)?.inputPoint,
};
});
this._lines = [...lines, ...extraLines];
const renderState = this.entityManager.getEntity<CustomRenderStateEntity>(
CustomRenderStateEntity,
);
renderState?.updateVersion();
});
}
}

View File

@@ -0,0 +1,19 @@
/*
* 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 { CustomLinesManager } from './custom-lines-manager';
export { CustomHoverService } from './custom-hover-service';
export { TreeService } from './tree-service';

View File

@@ -0,0 +1,505 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-params */
/* eslint-disable complexity */
import { nanoid } from 'nanoid';
import { inject, injectable } from 'inversify';
import {
FlowDocument,
type FlowNodeJSON,
} from '@flowgram-adapter/fixed-layout-editor';
import type {
DependencyTree,
DependencyTreeNode,
KnowledgeInfo,
PluginVersionInfo,
TableInfo,
} from '@coze-arch/bot-api/workflow_api';
import {
transformKnowledge,
transformPlugin,
transformTable,
isLoop,
} from '../utils';
import {
DependencyOrigin,
type EdgeItem,
NodeType,
type TreeNode,
} from '../typings';
@injectable()
export class TreeService {
@inject(FlowDocument) declare document: FlowDocument;
declare root: TreeNode;
edges: EdgeItem[] = [];
treeHistory: {
// 唯一标识符
id: string;
// 判断是否已经存在的两个条件
resourceId: string;
version?: string;
depth: number;
}[] = [];
declare globalJson: DependencyTree;
declare addChildrenArr: {
parentId: string;
json: FlowNodeJSON;
}[];
getUnCollapsedEdges() {
return this.edges.filter(e => !e.collapsed);
}
/**
* 用于检查是否有重复项
*/
getNodeDuplicateFromTree = (id: string, version?: string) => {
let duplicate = false;
const depth: number[] = [];
const dupId: string[] = [];
const matchArr = this.treeHistory.filter(
history =>
`${history.resourceId}${history.version || ''}` ===
`${id}${version || ''}`,
);
if (matchArr?.length) {
duplicate = true;
matchArr.forEach(item => {
depth.push(item.depth);
dupId.push(item.id);
});
}
return {
depth,
duplicate,
dupId,
};
};
traverseDFS(node: TreeNode, id: string): TreeNode | undefined {
if (node.id === id) {
return node;
} else {
if (!node.children?.length) {
return undefined;
}
for (const _node of node.children) {
const findNode = this.traverseDFS(_node, id);
if (findNode) {
return findNode;
}
}
return undefined;
}
}
getNodeByIdFromTree(id: string): TreeNode | undefined {
return this.traverseDFS(this.root, id);
}
/**
* 处理 knowledge、plugin、table 的逻辑
*/
transformDuplicateInfo = (
id: string,
info: KnowledgeInfo | PluginVersionInfo | TableInfo,
type: 'knowledge' | 'plugin' | 'table',
depth: number,
fromId: string,
) => {
const {
duplicate,
depth: dupDepth,
dupId,
} = this.getNodeDuplicateFromTree(id);
let newSchema: FlowNodeJSON;
if (type === 'knowledge') {
newSchema = transformKnowledge(depth + 1, info);
} else if (type === 'plugin') {
newSchema = transformPlugin(depth + 1, info);
} else {
newSchema = transformTable(depth + 1, info);
}
// 如果重复,判断添加线还是节点
if (duplicate) {
for (const [i, d] of dupDepth.entries()) {
// 只要有一个匹配,就加线。
// 兜底走加节点的逻辑
if (depth + 1 === d) {
// 存到线条中
this.edges.push({
from: fromId,
to: dupId[i],
});
return;
}
}
return newSchema;
}
// 否则,正常往 blocks 里添加数据
return newSchema;
};
dfsTransformNodeToSchema = (
depth: number,
node: DependencyTreeNode,
type?: NodeType,
parentId?: string,
): TreeNode | undefined => {
if (!node?.commit_id) {
return undefined;
}
let from = DependencyOrigin.APP;
if (node.is_library) {
from = DependencyOrigin.LIBRARY;
}
if (node.is_product) {
from = DependencyOrigin.SHOP;
}
const dependencies = node.dependency;
const children: TreeNode[] = [];
const nodeId = `${node.commit_id}_${nanoid(5)}`;
const {
duplicate,
depth: dupDepth,
dupId,
} = this.getNodeDuplicateFromTree(
node!.id as string,
node.workflow_version,
);
if (duplicate) {
for (const [i, d] of dupDepth.entries()) {
if (depth === d) {
this.edges.push({
from: parentId || '',
to: dupId[i],
});
return;
}
}
const loop = node.id && isLoop(node.id, this.globalJson);
// 重复节点,停止继续往下
const endData = {
id: nodeId,
type: 'custom',
data: {
id: node.id,
name: node.name,
type:
type || (node.is_chatflow ? NodeType.CHAT_FLOW : NodeType.WORKFLOW),
from,
collapsed: false,
version: node.workflow_version,
depth,
loop,
},
parent: [],
meta: {
isNodeEnd: true,
},
blocks: [],
};
return endData;
}
this.treeHistory.push({
id: nodeId,
resourceId: node.id as string,
version: node.workflow_version,
depth,
});
(dependencies?.knowledge_list || []).map(k => {
const newS = this.transformDuplicateInfo(
k.id as string,
k,
'knowledge',
depth,
nodeId,
);
if (newS) {
this.treeHistory.push({
id: newS.id,
resourceId: newS.data?.id,
version: newS.data?.version,
depth: newS.data?.depth,
});
children.push({
...newS,
data: newS.data || {},
type: newS.type as string,
parent: [],
children: [],
});
}
});
(dependencies?.table_list || []).map(t => {
const newS = this.transformDuplicateInfo(
t.id as string,
t,
'table',
depth,
nodeId,
);
if (newS) {
this.treeHistory.push({
id: newS.id,
resourceId: newS.data?.id,
version: newS.data?.version,
depth: newS.data?.depth,
});
children.push({
...newS,
data: newS.data || {},
type: newS.type as string,
parent: [],
children: [],
});
}
});
(dependencies?.plugin_version || []).map(p => {
const newS = this.transformDuplicateInfo(
p.id as string,
p,
'plugin',
depth,
nodeId,
);
if (newS) {
this.treeHistory.push({
id: newS.id,
resourceId: newS.data?.id,
version: newS.data?.version,
depth: newS.data?.depth,
});
children.push({
...newS,
data: newS.data || {},
type: newS.type as string,
parent: [],
children: [],
});
}
});
(dependencies?.workflow_version?.filter(v => v.id !== node.id) || []).map(
w => {
const workflowInfo = this.globalJson.node_list?.find(
_node => _node.id === w.id && _node.workflow_version === w.version,
);
const subWorkflowSchema = this.dfsTransformNodeToSchema(
depth + 1,
workflowInfo!,
undefined,
nodeId,
);
// 检测 workflow 的重复
if (subWorkflowSchema) {
this.treeHistory.push({
id: subWorkflowSchema.id,
resourceId: subWorkflowSchema.data?.id || '',
version: subWorkflowSchema.data?.version,
depth: subWorkflowSchema?.data?.depth,
});
children.push(subWorkflowSchema);
}
},
);
const isNodeEnd = !children.length;
return {
id: nodeId,
type: isNodeEnd ? 'custom' : 'split',
parent: [],
data: {
id: node.id,
name: node.name,
type:
type || (node.is_chatflow ? NodeType.CHAT_FLOW : NodeType.WORKFLOW),
from,
collapsed: false,
version: node.workflow_version,
depth,
},
...(isNodeEnd
? {
meta: {
isNodeEnd: true,
},
}
: {}),
children,
};
};
// 展开所有的子元素
dfsCloneCollapsedOpen(node: TreeNode): TreeNode {
const children = node.children?.map(c => this.dfsCloneCollapsedOpen(c));
return {
...node,
data: {
...node.data,
collapsed: false,
},
children,
};
}
// 根据 edges移动节点到另一个 TreeNode 的 children 下。
// 需要将内部的 collapsed 全部变成 open
cloneNode(node: TreeNode) {
return this.dfsCloneCollapsedOpen(node);
}
/**
* 绑定父元素 parent
*/
bindTreeParent(node: TreeNode, parent?: TreeNode) {
if (parent) {
node.parent.push(parent);
}
this.edges.forEach(edge => {
if (edge.to === node.id) {
const fromNode = this.getNodeByIdFromTree(edge.from);
if (fromNode) {
node.parent.push(fromNode);
}
}
});
node.children?.forEach(item => {
this.bindTreeParent(item, node);
});
}
/**
* 初始化数据,将数据从后端数据 json 变成 tree 结构
*/
transformSchema(json: DependencyTree) {
this.globalJson = json;
const { node_list = [] } = json;
const rootWorkflow = node_list.find(node => node.is_root);
if (!rootWorkflow) {
return undefined;
}
const root = this.dfsTransformNodeToSchema(0, rootWorkflow);
if (root) {
this.root = root;
// this.bindTreeParent(root);
}
}
dfsTreeJson(node: TreeNode): FlowNodeJSON {
let blocks: FlowNodeJSON[] = [];
const lines = this.getUnCollapsedEdges();
node?.children?.forEach(c => {
const connectLines = lines.filter(l => l.to === c.id);
if (c.data?.collapsed && connectLines?.length) {
const cloneNode = this.cloneNode(c);
connectLines.forEach(l => {
this.addChildrenArr.push({
parentId: l.from,
json: this.dfsTreeJson(cloneNode),
});
});
}
});
if (node?.children?.length) {
blocks = node.children
.filter(c => !c?.data?.collapsed)
.map(c => ({
id: `${c.id}_block`,
type: 'block',
blocks: [this.dfsTreeJson(c)],
}));
}
return {
id: node?.id,
type: node?.type,
data: node?.data,
meta: node?.meta,
blocks,
};
}
addNode(
json: FlowNodeJSON,
addItem: {
parentId: string;
json: FlowNodeJSON;
},
) {
if (json.id === addItem.parentId) {
const blockItem = {
id: `${addItem.json.id}_block`,
type: 'block',
blocks: [addItem.json],
};
if (json.blocks) {
json.blocks.push(blockItem);
} else {
json.blocks = [blockItem];
}
} else {
json.blocks?.forEach(block => {
this.addNode(block, addItem);
});
}
// 可能因此原本设置为结束的节点现在有 child 了
if (json.blocks?.length) {
if (json.type === 'custom') {
json.type = 'split';
}
if (json.meta) {
json.meta.isNodeEnd = false;
} else {
json.meta = {
isNodeEnd: true,
};
}
}
}
treeToFlowNodeJson() {
this.addChildrenArr = [];
const rootNodeJson = this.dfsTreeJson(this.root);
if (!rootNodeJson || !rootNodeJson?.id) {
const json = {
nodes: [],
};
this.document.fromJSON(json);
return json;
}
const json2 = {
nodes: [rootNodeJson],
};
this.addChildrenArr.forEach(item => {
this.addNode(rootNodeJson, item);
});
this.document.fromJSON(json2);
return json2;
}
}

View File

@@ -0,0 +1,19 @@
/*
* 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 { CustomLine, NodeType, DependencyOrigin, EdgeItem } from './line';
export { TreeNode } from './tree';

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type FlowNodeEntity,
type IPoint,
} from '@flowgram-adapter/fixed-layout-editor';
export interface CustomLine {
from: FlowNodeEntity;
to: FlowNodeEntity;
fromPoint: IPoint;
toPoint: IPoint;
activated?: boolean;
}
/**
* 资源 icon 类型
*/
export enum NodeType {
WORKFLOW, // 工作流
CHAT_FLOW, // 对话流
KNOWLEDGE, // 知识库
PLUGIN, // 插件
DATABASE, // 数据库
}
/**
* 资源来源
*/
export enum DependencyOrigin {
LIBRARY, // 资源库
APP, // App / Project
SHOP, // 商店
}
export interface EdgeItem {
from: string;
to: string;
collapsed?: boolean;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FlowNodeMeta } from '@flowgram-adapter/fixed-layout-editor';
export interface TreeNode {
id: string;
type: string;
meta?: FlowNodeMeta;
// collapsed、depth 放在 data 中
data: Record<string, any>;
parent: TreeNode[];
children?: TreeNode[];
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isEmpty } from 'lodash-es';
import {
type FlowNodeEntity,
FlowNodeBaseType,
} from '@flowgram-adapter/fixed-layout-editor';
export const getStoreNode = (node: FlowNodeEntity) => {
const isBlockOrderIcon =
node.flowNodeType === FlowNodeBaseType.BLOCK_ORDER_ICON;
const isBlockIcon = node.flowNodeType === FlowNodeBaseType.BLOCK_ICON;
return {
node: isBlockOrderIcon || isBlockIcon ? node.parent! : node,
updateCurrent: !(isBlockOrderIcon || isBlockIcon),
};
};
export const updateNodeExtInfo = (
renderNode: FlowNodeEntity,
info: Record<string, any>,
) => {
const { node, updateCurrent } = getStoreNode(renderNode);
if (!updateCurrent) {
renderNode.updateExtInfo(info);
}
if (isEmpty(node.getExtInfo())) {
return;
} else {
node.updateExtInfo(info);
}
};
export const getNodeExtInfo = (renderNode: FlowNodeEntity) => {
const { node } = getStoreNode(renderNode);
return node.getExtInfo();
};

View File

@@ -0,0 +1,31 @@
/*
* 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 {
getParentChildrenCount,
getTreeIdFromNodeId,
getNodeIdFromTreeId,
} from './node';
export { getLineId, calcDistance } from './line';
export { getStoreNode, updateNodeExtInfo, getNodeExtInfo } from './ext-info';
export {
getFrom,
transformKnowledge,
transformTable,
transformPlugin,
isLoop,
} from './transform-tree';
export { isDepEmpty } from './status';

View File

@@ -0,0 +1,38 @@
/*
* 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 { Bezier } from 'bezier-js';
import { type IPoint, Point } from '@flowgram-adapter/fixed-layout-editor';
import { type CustomLine } from '../typings';
import { getBezierVerticalControlPoints } from '../components/lines-render/utils';
export const getLineId = (line?: CustomLine) => {
if (!line) {
return undefined;
}
return `${line.from.id}${line.to.id}`;
};
export const calcDistance = (pos: IPoint, line?: CustomLine) => {
if (!line) {
return Number.MAX_SAFE_INTEGER;
}
const { fromPoint, toPoint } = line;
const controls = getBezierVerticalControlPoints(line.fromPoint, line.toPoint);
const bezier = new Bezier([fromPoint, ...controls, toPoint]);
return Point.getDistance(pos, bezier.project(pos));
};

View File

@@ -0,0 +1,39 @@
/*
* 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/fixed-layout-editor';
const getParentOfNoBlock = (
node: FlowNodeEntity,
): FlowNodeEntity | undefined => {
if (!node.parent) {
return undefined;
}
if (node.parent.flowNodeType === 'block') {
return getParentOfNoBlock(node.parent);
}
return node.parent;
};
export const getParentChildrenCount = (node: FlowNodeEntity) => {
const parent = getParentOfNoBlock(node);
return parent?.children?.length || 0;
};
export const getTreeIdFromNodeId = (id: string) =>
id.replace('$blockIcon$', '');
export const getNodeIdFromTreeId = (id: string) => `$blockIcon$${id}`;

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type DependencyTree } from '@coze-arch/bot-api/workflow_api';
export const isDepEmpty = (data?: DependencyTree) => {
if (!data) {
return true;
}
if (data.edge_list?.length) {
return false;
}
const rootNode = data.node_list?.[0]?.dependency || {};
const hasKnowledge = rootNode.knowledge_list?.length;
const hasPlugins = rootNode.plugin_version?.length;
const hasTable = rootNode.table_list?.length;
const hasSubWorkflow = rootNode.workflow_version?.length;
return !hasKnowledge && !hasPlugins && !hasTable && !hasSubWorkflow;
};

View File

@@ -0,0 +1,153 @@
/*
* 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 { nanoid } from 'nanoid';
import type {
DependencyTree,
DependencyTreeNode,
KnowledgeInfo,
PluginVersionInfo,
TableInfo,
WorkflowVersionInfo,
} from '@coze-arch/bot-api/workflow_api';
import { DependencyOrigin, NodeType } from '../typings';
import { NANO_ID_NUM } from '../constants';
export const getFrom = (project_id?: boolean, is_product?: boolean) => {
let from = DependencyOrigin.LIBRARY;
if (project_id) {
from = DependencyOrigin.APP;
}
if (is_product) {
from = DependencyOrigin.SHOP;
}
return from;
};
export const transformKnowledge = (depth: number, knowledge: KnowledgeInfo) => {
const from = getFrom(
Boolean(knowledge.project_id && knowledge.project_id !== '0'),
knowledge.is_product,
);
return {
id: `${knowledge.id}_${nanoid(NANO_ID_NUM)}`,
type: 'custom',
meta: {
isNodeEnd: true,
},
data: {
id: knowledge.id,
name: knowledge.name,
depth,
collapsed: false,
type: NodeType.KNOWLEDGE,
from,
version: undefined,
icon: knowledge.icon,
},
};
};
export const transformTable = (depth: number, table: TableInfo) => {
const from = getFrom(
Boolean(table.project_id && table.project_id !== '0'),
table.is_product,
);
return {
id: `${table.id}_${nanoid(NANO_ID_NUM)}`,
type: 'custom',
meta: {
isNodeEnd: true,
},
data: {
id: table.id,
name: table.name,
depth,
collapsed: false,
type: NodeType.DATABASE,
from,
version: undefined,
icon: table.icon,
},
};
};
export const transformPlugin = (depth: number, plugin: PluginVersionInfo) => {
const from = getFrom(
Boolean(plugin.project_id && plugin.project_id !== '0'),
plugin.is_product,
);
return {
id: `${plugin.id}_${plugin.version}`,
type: 'custom',
meta: {
isNodeEnd: true,
},
data: {
id: plugin.id,
name: plugin.name,
depth,
collapsed: false,
type: NodeType.PLUGIN,
from,
version: plugin.version,
icon: plugin.icon,
},
};
};
const findNode = (
tree: DependencyTree,
id?: string,
): DependencyTreeNode | undefined => {
if (!id) {
return undefined;
}
const nodeList = tree.node_list || [];
const node = nodeList.find(n => n?.id === id);
return node;
};
/**
* 判断是否循环
*/
export const isLoop = (id: string, json: DependencyTree) => {
const node = findNode(json, id);
// 只有 workflow 会存在循环,因此只需要关注 workflow_version
if (node?.dependency?.workflow_version?.length) {
return hasSameId(id, node.dependency.workflow_version, json);
}
return false;
};
// 判断循环
const hasSameId = (
id: string,
arr: WorkflowVersionInfo[],
json: DependencyTree,
): boolean => {
const hasSame = arr.some(n => n?.id === id);
if (hasSame) {
return true;
}
return arr.some(n => {
const node = findNode(json, n?.id);
return hasSameId(id, node?.dependency?.workflow_version || [], json);
});
};