feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,44 @@
/*
* 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 { LINE_OFFSET } from '../../../constants/lines';
export default function ArrowRenderer({
id,
pos,
strokeWidth,
}: {
id: string;
strokeWidth: number;
pos: {
x: number;
y: number;
};
}) {
return (
<path
d={`M ${pos.x - LINE_OFFSET},${pos.y - LINE_OFFSET} L ${pos.x},${
pos.y
} L ${pos.x - LINE_OFFSET},${pos.y + LINE_OFFSET}`}
strokeLinecap="round"
stroke={`url(#${id})`}
fill="none"
strokeWidth={strokeWidth}
/>
);
}

View File

@@ -0,0 +1,122 @@
/*
* 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 {
type WorkflowLineEntity,
POINT_RADIUS,
WorkflowLineRenderData,
} from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
import WithPopover from '../popover/with-popover';
import styles from '../index.module.less';
import ArrowRenderer from '../arrow';
import { STROKE_WIDTH_SLECTED, STROKE_WIDTH } from '../../../constants/points';
// import { AddPoint } from '@/components/add-point';
const PADDING = 12;
export interface BezierLineProps {
fromColor?: string;
toColor?: string;
color?: string; // 高亮颜色,优先级最高
selected?: boolean;
showControlPoints?: boolean;
line: WorkflowLineEntity;
version: string; // 用于控制 memo 刷新
}
export const BezierLineRender = React.memo(
WithPopover((props: BezierLineProps) => {
const { line, color, fromColor, toColor, selected } = props;
const renderData = line.getData(WorkflowLineRenderData);
const { bounds: bbox } = renderData;
const { position } = line;
// 相对位置
const toRelative = (p: IPoint) => ({
x: p.x - bbox.x + PADDING,
y: p.y - bbox.y + PADDING,
});
const fromPos = toRelative(position.from);
const toPos = toRelative(position.to);
// 真正连接线需要到的点的位置
const arrowToPos = {
x: toPos.x - POINT_RADIUS,
y: toPos.y,
};
const linearStartColor = fromPos.x < arrowToPos.x ? fromColor : toColor;
const linerarEndColor = fromPos.x < arrowToPos.x ? toColor : fromColor;
const strokeWidth = selected ? STROKE_WIDTH_SLECTED : STROKE_WIDTH;
const path = (
<path
d={renderData.path}
fill="none"
stroke={`url(#${line.id})`}
strokeWidth={strokeWidth}
className={line.processing ? styles.processingLine : ''}
/>
);
// const cls = clx('gedit-mindmap-line', {
// hovered,
// drawing,
// processing: props.processing
// });
return (
<>
<div
className="gedit-flow-activity-edge"
style={{
left: bbox.x - PADDING,
top: bbox.y - PADDING,
position: 'absolute',
}}
>
<svg
width={bbox.width + PADDING * 2}
height={bbox.height + PADDING * 2}
>
<defs>
<linearGradient
x1="0%"
y1="100%"
x2="100%"
y2="100%"
id={line.id}
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color || linearStartColor} offset="0%" />
<stop stopColor={color || linerarEndColor} offset="100%" />
</linearGradient>
</defs>
<g>
{path}
<ArrowRenderer
id={line.id}
pos={arrowToPos}
strokeWidth={strokeWidth}
/>
</g>
</svg>
</div>
</>
);
}),
);

View File

@@ -0,0 +1,117 @@
/*
* 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 {
POINT_RADIUS,
WorkflowLineRenderData,
} from '@flowgram-adapter/free-layout-editor';
import WithPopover from '../popover/with-popover';
import styles from '../index.module.less';
import { type BezierLineProps } from '../bezier-line';
import ArrowRenderer from '../arrow';
import { STROKE_WIDTH, STROKE_WIDTH_SLECTED } from '../../../constants/points';
/**
* 折叠线
*/
export const FoldLineRender = React.memo(
WithPopover((props: BezierLineProps) => {
const { selected, color, line } = props;
const { to } = line.position;
const strokeWidth = selected ? STROKE_WIDTH_SLECTED : STROKE_WIDTH;
// 真正连接线需要到的点的位置
const arrowToPos = {
x: to.x - POINT_RADIUS,
y: to.y,
};
const renderData = line.getData(WorkflowLineRenderData);
// const bounds = line.bezier.foldBounds
// const points = line.bezier.foldPoints
//
// const debug = (
// <>
// <div
// style={{
// left: bounds.left,
// top: bounds.top,
// width: bounds.width,
// height: bounds.height,
// position: 'absolute',
// background: 'red',
// zIndex: 1000,
// opacity: 0.3,
// }}
// />
// {points.map((p, i) => (
// <div
// key={i}
// style={{
// left: p.x,
// top: p.y,
// width: 10,
// height: 10,
// marginTop: -5,
// marginBottom: -5,
// position: 'absolute',
// background: 'blue',
// zIndex: 1000,
// }}
// />
// ))}
// </>
// )
return (
<div
className="gedit-flow-activity-edge"
style={{ position: 'absolute' }}
>
<svg overflow="visible">
<defs>
<linearGradient
x1="0%"
y1="100%"
x2="100%"
y2="100%"
id={line.id}
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} offset="100%" />
</linearGradient>
</defs>
<g>
<path
d={renderData.path}
fill="none"
strokeLinecap="round"
stroke={color}
strokeWidth={strokeWidth}
className={line.processing ? styles.processingLine : ''}
/>
<ArrowRenderer
id={line.id}
pos={arrowToPos}
strokeWidth={strokeWidth}
/>
</g>
</svg>
</div>
);
}),
);

View File

@@ -0,0 +1,10 @@
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
.processing-line {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}

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.
*/
import React from 'react';
import { FlowRendererRegistry } from '@flowgram-adapter/free-layout-editor';
import {
useService,
WorkflowHoverService,
} from '@flowgram-adapter/free-layout-editor';
const LINE_POPOVER = 'line-popover';
export default function WithPopover(Component) {
return function WrappedComponent(props) {
const hoverService = useService<WorkflowHoverService>(WorkflowHoverService);
const renderRegistry =
useService<FlowRendererRegistry>(FlowRendererRegistry);
const Popover =
renderRegistry.tryToGetRendererComponent(LINE_POPOVER)?.renderer;
const { line } = props;
const isHovered = hoverService.isHovered(line._id);
return (
<>
<Component {...props} />
{Popover ? <Popover line={line} isHovered={isHovered} /> : null}
</>
);
};
}

View File

@@ -0,0 +1,17 @@
.selector-bounds-background {
cursor: move;
display: none;
// background: rgba(255, 255, 255, 0);
}
.selector-bounds-forground {
cursor: move;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
outline: 1px solid var(--g-playground-selectBox-outline);
// 在节点的上边
z-index: 33;
background-color: var(--g-playground-selectBox-background);
}

View File

@@ -0,0 +1,52 @@
/*
* 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 { domUtils } from '@flowgram-adapter/common';
import { type FlowSelectorBoundsLayerOptions } from '@flowgram-adapter/free-layout-editor';
import { SelectionService, useService } from '@flowgram-adapter/free-layout-editor';
import { getSelectionBounds } from '../../utils/selection-utils';
import styles from './index.module.less';
/**
* 选择框
* @param props
* @constructor
*/
export const SelectorBounds: React.FC<
FlowSelectorBoundsLayerOptions
> = props => {
const selectService = useService<SelectionService>(SelectionService);
const bounds = getSelectionBounds(selectService, true);
if (bounds.width === 0 || bounds.height === 0) {
// domUtils.setStyle(domNode, {
// display: 'none',
// });
return <></>;
}
const style = {
display: 'block',
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
};
// domUtils.setStyle(domNode, style);
return <div className={styles.selectorBoundsForground} style={style} />;
};

View File

@@ -0,0 +1,28 @@
/*
* 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 styles from './index.module.less';
// demo 环境自绘 cross-hair正式环境使用 IconAdd
export default function CrossHair(): JSX.Element {
return (
<div className={styles.symbol}>
<div className={styles.crossHair} />
</div>
);
}

View File

@@ -0,0 +1,157 @@
/* stylelint-disable selector-class-pattern */
// 背景的白色圆圈
.workflow-point {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
// 非 hover 状态下的样式
border: none;
border-radius: 50%;
& > .symbol {
opacity: 0;
}
.bg-circle {
position: absolute;
transform: scale(0.5);
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: #fff;
border-radius: 50%;
transition: all 0.2s linear 0s;
}
.bg {
position: relative;
transform: scale(0.4, 0.4);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #9197f1;
border-radius: 50%;
transition: all 0.2s linear 0s;
&.hasError {
background: red;
}
.symbol {
pointer-events: none;
position: absolute;
width: 14px;
height: 14px;
color: #fff;
opacity: 0;
transition: opacity 0.2s linear 0s;
& > svg {
width: 14px;
height: 14px;
}
}
.focus-circle {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 8px;
height: 8px;
opacity: 0;
background: #9197f1;
border-radius: 50%;
transition: opacity 0.2s linear 0s;
}
}
&.linked .bg:not(.hasError) {
background: #4d53e8;
}
&.hovered .bg:not(.hasError) {
cursor: crosshair;
transform: scale(1, 1);
background: #4d53e8;
border: none;
& > .symbol {
opacity: 1;
}
}
&:hover .bg:is(.hasError) {
transform: scale(1, 1);
background: red;
border: none;
& > .symbol {
opacity: 1;
}
}
}
.cross-hair {
position: relative;
top: 2px;
left: 2px;
&::after,
&::before {
content: '';
position: absolute;
background: #fff;
border-radius: 2px;
}
&::after {
left: 4px;
width: 2px;
height: 6px;
box-shadow: 0 4px #fff;
}
&::before {
top: 4px;
width: 6px;
height: 2px;
box-shadow: 4px 0 #fff;
}
}
.warning {
display: flex;
align-items: center;
justify-content: center;
}
.tooltip {
transform: translateY(-8px);
}

View File

@@ -0,0 +1,167 @@
/*
* 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 ReactDOM from 'react-dom';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { Tooltip } from '@coze-arch/coze-design';
import { useService } from '@flowgram-adapter/free-layout-editor';
import {
usePlaygroundReadonlyState,
WorkflowDragService,
WorkflowHoverService,
WorkflowLinesManager,
type WorkflowPortEntity,
} from '@flowgram-adapter/free-layout-editor';
import { PORT_BG_CLASS_NAME } from '../../constants/points';
import { Warning } from './warning';
import CrossHair from './cross-hair';
import styles from './index.module.less';
export interface WorkflowPortRenderProps {
entity: WorkflowPortEntity;
onClick?: (event: React.MouseEvent, port: WorkflowPortEntity) => void;
}
export const WorkflowPortRender: React.FC<WorkflowPortRenderProps> =
React.memo<WorkflowPortRenderProps>(props => {
const dragService = useService<WorkflowDragService>(WorkflowDragService);
const hoverService = useService<WorkflowHoverService>(WorkflowHoverService);
const linesManager = useService<WorkflowLinesManager>(WorkflowLinesManager);
const { entity, onClick } = props;
const { portType, portID, relativePosition, disabled, errorMessage } =
entity as WorkflowPortEntity & {
errorMessage?: string;
};
const [targetElement, setTargetElement] = useState(entity.targetElement);
const [posX, updatePosX] = useState(relativePosition.x);
const [posY, updatePosY] = useState(relativePosition.y);
const [hovered, setHovered] = useState(false);
const [linked, setLinked] = useState(Boolean(entity?.lines?.length));
const [hasError, setHasError] = useState(props.entity.hasError);
const readonly = usePlaygroundReadonlyState();
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
const isMouseCenterButton = e.button === 1;
if (portType === 'input' || disabled || isMouseCenterButton) {
return;
}
e.stopPropagation();
e.preventDefault();
dragService.startDrawingLine(entity, e);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- custom
[dragService, portType, portID],
);
useEffect(() => {
// useEffect 时序问题可能导致 port.hasError 非最新,需重新触发一次 validate
props.entity.validate();
setHasError(props.entity.hasError);
const dispose = props.entity.onEntityChange(() => {
// 如果有挂载的节点,不需要更新位置信息
if (entity.targetElement) {
if (entity.targetElement !== targetElement) {
setTargetElement(entity.targetElement);
}
return;
}
const newPos = props.entity.relativePosition;
// 加上 round 避免点位抖动
updatePosX(Math.round(newPos.x));
updatePosY(Math.round(newPos.y));
});
const dispose2 = hoverService.onHoveredChange(id => {
setHovered(hoverService.isHovered(entity.id));
});
const dispose3 = props.entity.onErrorChanged(() => {
setHasError(props.entity.hasError);
});
const dispose4 = linesManager.onAvailableLinesChange(() => {
setTimeout(() => {
setLinked(Boolean(entity?.lines?.length));
}, 0);
});
return () => {
dispose.dispose();
dispose2.dispose();
dispose3.dispose();
dispose4.dispose();
};
}, [props.entity, hoverService, entity, targetElement, linesManager]);
// 监听变化
const className = classNames(styles.workflowPoint, {
[styles.hovered]:
!readonly && hovered && !disabled && portType !== 'input',
// 有线条链接的时候深蓝色小圆点
[styles.linked]: linked,
});
const icon = useMemo(() => {
const iconComp = (
<div
className={classNames({
[styles.bg]: true,
[PORT_BG_CLASS_NAME]: true,
'workflow-point-bg': true,
[styles.hasError]: hasError,
})}
>
{hasError ? <Warning /> : <CrossHair />}
</div>
);
if (hasError && errorMessage) {
return (
<Tooltip
className={styles.tooltip}
content={errorMessage}
trigger="hover"
position="top"
>
{iconComp}
</Tooltip>
);
}
return iconComp;
}, [hasError, errorMessage]);
const content = (
<div
className={className}
style={targetElement ? undefined : { left: posX, top: posY }}
onClick={e => onClick?.(e, entity)}
onMouseDown={onMouseDown}
data-port-entity-id={entity.id}
data-testid="bot-edit-multi-agent-flow-node-add-button"
>
<div
className={classNames(styles.bgCircle, 'workflow-bg-circle')}
></div>
{icon}
<div className={styles['focus-circle']} />
</div>
);
if (targetElement) {
return ReactDOM.createPortal(content, targetElement);
}
return content;
});

View File

@@ -0,0 +1,46 @@
/*
* 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 classNames from 'classnames';
import styles from './index.module.less';
export const Warning = () => (
<div className={classNames(styles.symbol, styles.warning)}>
<svg
style={{ width: 10, height: 10 }}
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
id="path1"
fill="#ffffff"
stroke="none"
d="M 12 0 C 10.674479 0 9.6 1.074528 9.6 2.4 L 9.6 14.4 C 9.6 15.725521 10.674479 16.799999 12 16.799999 C 13.325521 16.799999 14.4 15.725521 14.4 14.4 L 14.4 2.4 C 14.4 1.074528 13.325521 0 12 0 Z"
/>
<path
id="path2"
fill="#ffffff"
stroke="none"
d="M 12 19.200001 C 10.674479 19.200001 9.6 20.274479 9.6 21.6 C 9.6 22.925518 10.674479 24 12 24 C 13.325521 24 14.4 22.925518 14.4 21.6 C 14.4 20.274479 13.325521 19.200001 12 19.200001 Z"
/>
</svg>
</div>
);