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,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,34 @@
# @coze-devops/common-modules
> Project template for react component with storybook.
## 目录结构说明
``` bash
├── __tests__
├── .storybook
├── config
├── src
│ ├── assets ## 公共静态资源
│ │ ├── react.svg
│ │ └── rspack.png
│ ├── components ## 公共组件
│ ├── hooks ## 公共组件
│ ├── index.tsx ## 对外统一出口, 导出内容类型可以是component, hook, util, typing
│ ├── modules ## 模块集合子目录按业务模块划分另外index.tsx中导出的资源都是来自于modules目录
│ │ └── query-trace
│ ├── services ## 请求 api 封装
│ ├── styles ## 公共样式
│ ├── typings ## 公共类型
│ ├── typings.d.ts
│ └── utils ## 公共工具库
├── stories ## 文档
├── .eslintrc.js
├── .stylelintrc.js
├── OWNERS
├── package.json
├── README.md
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.ts
```

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,11 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {
'@coze-arch/max-line-per-function': 'off',
'max-lines-per-function': 'off',
'max-lines': 'off',
},
});

View File

@@ -0,0 +1,70 @@
{
"name": "@coze-devops/common-modules",
"version": "0.0.1",
"private": true,
"description": "common business modules for devops",
"license": "Apache-2.0",
"author": "wangke.789@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.ts",
"./query-trace": "./src/modules/query-trace/index.ts",
"./tree": "./src/modules/query-trace/components/tree/index.tsx"
},
"main": "src/index.tsx",
"typesVersions": {
"*": {
"query-trace": [
"./src/modules/query-trace/index.ts"
],
"tree": [
"./src/modules/query-trace/components/tree/index.tsx"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-flags": "workspace:*",
"@coze-arch/bot-hooks": "workspace:*",
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@dagrejs/dagre": "^1.0.2",
"@visactor/vgrammar": "0.12.5-alpha.4",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"dayjs": "^1.11.7",
"lodash-es": "^4.17.21",
"reactflow": "^11.8.2"
},
"devDependencies": {
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/lodash-es": "^4.17.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1 @@
公共组件目录

View File

@@ -0,0 +1 @@
公共hook目录

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 {
TraceFlamethread,
TraceTree,
useSpanTransform,
} from './modules/query-trace';

View File

@@ -0,0 +1,71 @@
/*
* 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 GlobalStyle,
type RectStyle,
type LabelStyle,
type LabelText,
} from './typing';
export const defaultRectStyle: RectStyle = {
normal: {
fill: '#F7F7FA',
stroke: '#1D1C2314',
lineWidth: 1,
lineDash: [],
},
hover: {
lineWidth: 1,
lineDash: [],
},
select: {
lineWidth: 1,
lineDash: [],
},
};
export const defaultGlobalStyle: GlobalStyle = {
height: '100%',
width: '100%',
padding: {
top: 0,
right: 24,
bottom: 24,
left: 0,
},
};
export const defaultDatazoomDecimals = 1;
export const defaultVisibleRowCount = 6;
export const defaultRowHeight = 42;
export const defaultVisibleColumnCount = 6; // 13 // 8
export const defaultLabelStyle: LabelStyle = {
position: 'inside-left',
fontSize: 12,
fill: '#212629',
};
export const defaultLabelText: LabelText = (datum, element, params) =>
`${datum.start}-${datum.end}`;
// xScale的padding(解决hover后rect边框被截断问题)
export const scrollbarMargin = 10;
export const datazoomHeight = 20;
export const datazoomDecimals = 0;
export const datazoomPaddingBottom = 18;

View File

@@ -0,0 +1,692 @@
/*
* 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 {
useRef,
useEffect,
type FC,
useMemo,
useCallback,
useLayoutEffect,
useState,
} from 'react';
import { pick, uniqWith } from 'lodash-es';
import {
View,
type ViewSpec,
ComponentEnum,
GrammarMarkType,
type GrammarScaleType,
type MarkSpec,
type IView,
} from '@visactor/vgrammar';
import type {
FlamethreadProps,
RectNode,
RectStyle,
LabelStyle,
LabelText,
Tooltip,
IElement,
GlobalStyle,
InteractionEventHandler,
} from './typing';
import {
datazoomDecimals,
datazoomHeight,
datazoomPaddingBottom,
defaultGlobalStyle,
defaultLabelStyle,
defaultLabelText,
defaultRectStyle,
defaultRowHeight,
defaultVisibleColumnCount,
scrollbarMargin,
} from './config';
export type {
FlamethreadProps,
RectNode,
RectStyle,
LabelStyle,
LabelText,
Tooltip,
IElement,
GlobalStyle,
InteractionEventHandler,
};
export const Flamethread: FC<FlamethreadProps> = props => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<IView | null>(null);
const [viewSize, setViewSize] = useState({ width: 0, height: 0 });
const {
flamethreadData,
rowHeight = defaultRowHeight,
visibleColumnCount = defaultVisibleColumnCount,
tooltip,
rectStyle: globalRectStyle,
labelStyle: _globalLabelStyle,
globalStyle: _globalStyle,
axisLabelSuffix,
labelText,
selectedKey,
disableViewScroll = false,
enableAutoFit = false,
onClick,
} = props;
const genRectStyle = useCallback(
(rectStyle?: RectStyle): RectStyle => ({
normal: Object.assign(
{},
defaultRectStyle.normal,
globalRectStyle?.normal,
rectStyle?.normal,
),
hover: Object.assign(
{},
defaultRectStyle.hover,
globalRectStyle?.hover,
rectStyle?.hover,
),
select: Object.assign(
{},
defaultRectStyle.select,
globalRectStyle?.select,
rectStyle?.select,
),
}),
[globalRectStyle],
);
const genLabelStyle = useCallback(
(labelStyle?: LabelStyle): LabelStyle =>
Object.assign({}, defaultLabelStyle, _globalLabelStyle, labelStyle),
[_globalLabelStyle],
);
const globalLabelStyle = useMemo(
() => Object.assign({}, defaultLabelStyle, _globalLabelStyle),
[_globalLabelStyle],
);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- 计算需要
const topOffset = datazoomHeight + datazoomPaddingBottom + 8;
const globalStyle: GlobalStyle = useMemo(
() => Object.assign({}, defaultGlobalStyle, _globalStyle),
[_globalStyle],
);
const totalRowHeight = useMemo(() => {
const rowCount = uniqWith(
flamethreadData,
(node0: RectNode, node1: RectNode) => node0.rowNo === node1.rowNo,
).length;
return rowCount * rowHeight;
}, [flamethreadData]);
// 此参数含义: 可视窗口Height / 火焰图Height
const yScaleRangeFactor = useMemo(() => {
const rowCount = uniqWith(
flamethreadData,
(node0: RectNode, node1: RectNode) => node0.rowNo === node1.rowNo,
).length;
return rowCount !== 0
? ((viewRef.current?.getViewBox().height() || 300) - topOffset) /
(rowCount * rowHeight)
: 1;
}, [flamethreadData, viewSize.height]);
const spec = useMemo(() => {
const orgData = flamethreadData.map(node => {
const rectStyle = genRectStyle(node.rectStyle);
const labelStyle = genLabelStyle(node.labelStyle);
return {
...node,
rectStyle,
labelStyle,
};
});
const marks = [
{
type: GrammarMarkType.component,
componentType: ComponentEnum.axis,
id: 'xAxis',
scale: 'xScale',
axisType: 'line',
tickCount: visibleColumnCount,
dependency: ['viewBox'],
encode: {
update: (scale0, elment, params) => {
const scale = params.xScale;
const range = scale.range() as number[];
const tickData = scale.tickData(visibleColumnCount);
const dx =
tickData.length > 1
? // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- 计算需要
(range[1] - range[0]) / (tickData.length - 1) / 2
: 0;
return {
verticalFactor: -1,
x: params.viewBox.x1,
y: params.viewBox.y1 + topOffset,
start: { x: 0, y: 0 },
end: { x: params.viewBox.width(), y: 0 },
tick: { visible: false },
label: {
style: { dx: -dx },
formatMethod: (_value: string) => {
const value = Number(_value);
// 特化逻辑: 隐藏0刻度
if (dx > 0 && value === 0) {
return '';
}
return value !== 0 && axisLabelSuffix !== undefined
? `${value}${axisLabelSuffix}`
: value;
},
},
};
},
},
},
{
type: GrammarMarkType.component,
componentType: ComponentEnum.grid,
tickCount: visibleColumnCount, // vgrammer库的类型写的不严谨实际是可用的
scale: 'xScale',
gridType: 'line',
gridShape: 'line',
dependency: ['viewBox'],
// dependency: ["viewBox"],
encode: {
update: (scale, elment, params) => ({
verticalFactor: -1,
length: params.viewBox.height() - topOffset,
x: params.viewBox.x1,
x1: params.viewBox.x2,
y: params.viewBox.y1 + topOffset,
start: { x: 0, y: 0 },
end: { x: params.viewBox.width(), y: 0 },
style: { stroke: '#ccc', lineWidth: 1, lineDash: [] },
}),
},
},
{
type: GrammarMarkType.group,
dependency: ['viewBox'],
encode: {
update: (scale, elment: IElement, params) => ({
x: params.viewBox.x1,
y: params.viewBox.y1 + topOffset,
width: params.viewBox.width(),
height: params.viewBox.height() - topOffset,
clip: true,
}),
},
marks: [
{
type: GrammarMarkType.rect,
id: 'rect',
from: { data: 'orgData' },
groupBy: 'start',
key: 'rowNo',
encode: {
update: {
x: { scale: 'xScale', field: 'start' },
x1: { scale: 'xScale', field: 'end' },
y: { scale: 'yScale', field: 'rowNo', band: 0.07 },
// height: { scale: 'yScale', band: 0.86 },
height: rowHeight - 4,
// height: { scale: "yScale", band: 0.7, offset: 0.15 },
fill: (datum, element, params) =>
datum?.rectStyle?.normal?.fill,
innerBorder: (datum, element, params) => {
const { stroke, lineWidth, lineDash } =
datum.rectStyle.normal;
return {
stroke: lineWidth !== 0 ? stroke : null,
lineWidth,
lineDash,
visible: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- 尺寸计算,无须处理
distance: lineWidth / 2,
};
},
},
hover2: {
fill: (datum, element, params) =>
datum?.rectStyle?.hover?.fill ??
datum?.rectStyle?.normal?.fill,
innerBorder: (datum, element, params) => {
const { stroke, lineWidth, lineDash } = Object.assign(
{},
datum?.rectStyle?.normal,
datum?.rectStyle?.hover,
);
return {
stroke: lineWidth !== 0 ? stroke : null,
lineWidth,
lineDash,
visible: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- 尺寸计算,无须定义常量
distance: lineWidth / 2,
};
},
zIndex: 2,
},
select2: {
fill: (datum, element, params) =>
datum?.rectStyle?.select?.fill ??
datum?.rectStyle?.normal?.fill,
innerBorder: (datum, element, params) => {
const { stroke, lineWidth, lineDash } = Object.assign(
{},
datum?.rectStyle?.normal,
datum?.rectStyle?.select,
);
return {
stroke: lineWidth !== 0 ? stroke : null,
lineWidth,
lineDash,
visible: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- 尺寸计算,无须定义常量
distance: lineWidth / 2,
};
},
zIndex: 1,
},
},
},
{
type: GrammarMarkType.component,
componentType: ComponentEnum.label,
target: 'rect',
labelStyle: {
position: globalLabelStyle.position,
textStyle: {
fontSize: globalLabelStyle.fontSize,
},
animation: false,
overlap: {
hideOnHit: false,
clampForce: false,
strategy: [{ type: 'position', position: ['top-left'] }],
},
},
encode: {
update: {
pickable: false, // vgrammer库的类型写的不严谨
text: labelText ?? defaultLabelText,
fill: (datum, element, params) => datum?.labelStyle.fill,
},
},
},
],
},
{
type: GrammarMarkType.component,
componentType: ComponentEnum.datazoom,
id: 'dataZoom',
dependency: ['viewBox'],
preview: {
data: 'table',
x: { scale: 'dataZoomXScale', field: ['start', 'end'] },
y: { scale: 'dataZoomYScale', field: 'rowNo' },
},
encode: {
update: (scale, elment, params) => ({
showDetail: false,
x: params.viewBox.x1,
y: params.viewBox.y1,
size: { width: params.viewBox.width(), height: datazoomHeight },
// start: 0,
// end: 1,
// fill: '#ff0000',
minSpan: 0.01,
selectedBackgroundStyle: {
fill: '#B4BAF6',
},
brushSelect: false,
startHandlerStyle: {
symbolType:
'M-0.5-2.4h0.9c0.4,0,0.7,0.3,0.7,0.7v3.3c0,0.4-0.3,0.7-0.7,0.7h-0.9c-0.4,0-0.7-0.3-0.7-0.7v-3.3\nC-1.2-2-0.9-2.4-0.5-2.4z M-0.4-1.4L-0.4-1.4c0,0,0,0.1,0,0.1v2.6c0,0.1,0,0.1,0,0.1l0,0c0,0,0-0.1,0-0.1v-2.6\nC-0.4-1.4-0.4-1.4-0.4-1.4z M0.3-1.4L0.3-1.4c0,0,0,0.1,0,0.1v2.6c0,0.1,0,0.1,0,0.1l0,0c0,0,0-0.1,0-0.1v-2.6\nC0.3-1.4,0.3-1.4,0.3-1.4z;',
fill: '#ffffff',
scaleX: 1.2,
scaleY: 1.2,
stroke: '#aeb5be',
lineWidth: 1,
size: 20,
},
middleHandlerStyle: {
visible: false,
},
endHandlerStyle: {
symbolType:
'M-0.5-2.4h0.9c0.4,0,0.7,0.3,0.7,0.7v3.3c0,0.4-0.3,0.7-0.7,0.7h-0.9c-0.4,0-0.7-0.3-0.7-0.7v-3.3\nC-1.2-2-0.9-2.4-0.5-2.4z M-0.4-1.4L-0.4-1.4c0,0,0,0.1,0,0.1v2.6c0,0.1,0,0.1,0,0.1l0,0c0,0,0-0.1,0-0.1v-2.6\nC-0.4-1.4-0.4-1.4-0.4-1.4z M0.3-1.4L0.3-1.4c0,0,0,0.1,0,0.1v2.6c0,0.1,0,0.1,0,0.1l0,0c0,0,0-0.1,0-0.1v-2.6\nC0.3-1.4,0.3-1.4,0.3-1.4z;',
fill: '#ffffff',
scaleX: 1.2,
scaleY: 1.2,
stroke: '#aeb5be',
lineWidth: 1,
size: 20,
},
startTextStyle: {
padding: 8,
textStyle: {
fontSize: 12,
lineHeight: '130%',
fill: '#606773',
// fill: '#ff0000',
},
formatMethod: (value: number) => value.toFixed(datazoomDecimals),
},
endTextStyle: {
padding: 8,
textStyle: {
fontSize: 12,
lineHeight: '130%',
fill: '#606773',
},
formatMethod: (value: number) => value.toFixed(datazoomDecimals),
},
}),
},
},
] as MarkSpec[];
const padding = {
top: 3,
right: 0,
bottom: 0,
left: 0,
};
if (yScaleRangeFactor < 1) {
marks.unshift({
type: GrammarMarkType.component,
componentType: ComponentEnum.scrollbar,
direction: 'vertical',
id: 'verticalScrollbar',
dependency: ['viewBox', 'yScale'],
encode: {
update: (scale, elment, params) => {
const { yScale } = params;
const curRangeFactor = yScale?.rangeFactor?.() ?? [
0,
yScaleRangeFactor,
];
return {
x: params.viewBox.x2 + scrollbarMargin,
y: params.viewBox.y1 + topOffset,
height: params.viewBox.height() - topOffset,
range: [curRangeFactor[1], curRangeFactor[0]],
};
},
},
});
padding.right = 22;
}
const spec0: ViewSpec = {
padding,
background: globalStyle.background,
data: [
{
id: 'orgData',
values: orgData,
},
{
id: 'markData',
source: 'orgData',
},
],
scales: [
{
id: 'xScale',
type: 'linear' as GrammarScaleType,
domain: { data: 'markData', field: ['start', 'end'] },
dependency: ['viewBox'],
range: (scale, params) => [0, params.viewBox.width()],
nice: true,
},
{
id: 'yScale',
type: 'band',
domain: { data: 'markData', field: 'rowNo' },
dependency: ['viewBox'],
range: (scale, params) => {
const vHeight = params.viewBox.height() - topOffset;
const height = yScaleRangeFactor <= 1 ? vHeight : totalRowHeight;
return [0, height];
},
padding: 0,
round: false,
},
{
id: 'dataZoomXScale',
type: 'linear',
domain: { data: 'orgData', field: ['start', 'end'] },
dependency: ['viewBox'],
range: (scale, params) => [0, params.viewBox.width()],
},
{
id: 'dataZoomYScale',
type: 'band',
domain: { data: 'orgData', field: 'rowNo' },
dependency: ['viewBox'],
range: (scale, params) => [params.viewBox.height(), 0],
padding: 0.05,
round: true,
},
],
marks,
};
return spec0;
}, [
flamethreadData,
visibleColumnCount,
globalLabelStyle.position,
globalLabelStyle.fontSize,
labelText,
yScaleRangeFactor,
totalRowHeight,
globalStyle.padding,
globalStyle.background,
genRectStyle,
genLabelStyle,
axisLabelSuffix,
]);
const updateSelectedKey = useCallback(
(view: IView) => {
const rectElm = view?.getMarkById('rect');
const elements = rectElm?.elements;
elements?.forEach(element => {
element?.removeState('select2');
});
elements
?.filter(element => {
const datum = element.getDatum();
return datum.key === selectedKey;
})[0]
?.addState('select2');
},
[selectedKey],
);
// 创建/更新view
useLayoutEffect(() => {
const initializeYScale = (view: IView) => {
const yScale = view?.getScaleById('yScale');
yScale?.setRangeFactor([0, yScaleRangeFactor]);
yScale?.commit();
};
const initializeScale = (view: IView) => {
initializeYScale(view);
};
const registerEvent = (view: IView) => {
const rectElm = view?.getMarkById('rect');
// rect点击事件
rectElm?.addEventListener('click', ((event, element) => {
onClick?.(event, element);
}) as InteractionEventHandler);
// rect hover高亮
view?.interaction('element-highlight', {
selector: 'rect',
highlightState: 'hover2',
});
view?.interaction('element-highlight', {
trigger: 'click',
// triggerOff: "view:click",
triggerOff: 'swipe',
selector: 'rect',
highlightState: 'select2',
});
if (!disableViewScroll) {
view.interaction('view-scroll', {
scaleY: 'yScale',
});
}
// rect hover显示tooltip
if (tooltip) {
view?.interaction('tooltip', {
selector: 'rect',
...tooltip,
});
}
};
if (containerRef.current && !viewRef.current) {
const view = new View({
autoFit: enableAutoFit,
container: containerRef.current,
});
view?.on('change', (...args) => {
const event = args[0];
const { start, end } = event.detail;
const xScale = view.getScaleById('xScale');
xScale?.setRangeFactor([start, end]);
xScale?.commit();
view?.run();
});
view?.on('scrollDrag', e => {
const direction = e?.target?.attribute?.direction;
if (direction === 'vertical') {
const range = e.detail.value;
const yScale = view.getScaleById('yScale');
yScale?.setRangeFactor(range);
yScale?.commit();
view.run();
}
});
view.parseSpec(spec);
initializeScale(view);
registerEvent(view);
view.run();
updateSelectedKey(view);
view.run();
viewRef.current = view;
} else if (viewRef.current) {
const view = viewRef.current;
view.updateSpec(spec);
initializeScale(view);
registerEvent(view);
view.run({ reuse: false });
updateSelectedKey(view);
view.run();
}
}, [
spec,
tooltip,
yScaleRangeFactor,
onClick,
visibleColumnCount,
flamethreadData,
]);
useEffect(() => {
if (viewRef.current) {
updateSelectedKey(viewRef.current);
}
}, [selectedKey]);
useEffect(
() => () => {
if (viewRef.current) {
viewRef.current.release();
}
viewRef.current = null;
},
[],
);
useEffect(() => {
const resizeObserver = new ResizeObserver(params => {
const width = params[0].target.clientWidth;
const height = params[0].target.clientHeight;
if (width !== undefined && height !== undefined && viewRef.current) {
viewRef.current.resize(width, height);
setViewSize({
width,
height,
});
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
}, []);
return (
<div
style={{
...pick(globalStyle, ['width', 'height']),
}}
ref={containerRef}
/>
);
};
export default Flamethread;

View File

@@ -0,0 +1,85 @@
/*
* 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 CSSProperties } from 'react';
import {
type InteractionEventHandler,
type TooltipSpec,
type ViewSpec,
type IElement,
} from '@visactor/vgrammar';
export type { IElement, InteractionEventHandler, TooltipSpec };
export interface RectStyleAttrs {
fill?: string;
stroke?: string;
lineWidth?: number;
lineDash?: number[];
}
export interface RectStyle {
normal?: RectStyleAttrs;
hover?: RectStyleAttrs;
select?: RectStyleAttrs;
}
export interface LabelStyle {
position?: string;
fontSize?: number;
fill?: string;
}
export interface RectNode {
key: string;
rowNo: number;
start: number;
end: number;
rectStyle?: RectStyle;
labelStyle?: Pick<LabelStyle, 'fill'>;
// 其他字段,会透传
extra?: unknown;
}
export type Tooltip = Pick<TooltipSpec, 'title' | 'content'>;
export type GlobalStyle = Pick<CSSProperties, 'width' | 'height'> &
Pick<ViewSpec, 'padding' | 'background'>;
export type LabelText = (
datum: RectNode,
element: IElement,
params: unknown,
) => string;
export interface FlamethreadProps {
flamethreadData: RectNode[];
rectStyle?: RectStyle;
labelStyle?: LabelStyle;
labelText?: LabelText;
tooltip?: Tooltip;
globalStyle?: GlobalStyle;
rowHeight?: number;
visibleColumnCount?: number;
// valuePerColumn?: number;
datazoomDecimals?: number;
axisLabelSuffix?: string;
selectedKey?: string;
disableViewScroll?: boolean;
enableAutoFit?: boolean;
onClick?: InteractionEventHandler;
}

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 { type EdgeProps } from 'reactflow';
import { SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { CommonNode } from '../custom-nodes';
import { CommonEdge } from '../custom-edges';
import { NodeEdgeCategory } from '.';
export const CUSTOM_NODES = {
[SpanCategory.Unknown]: CommonNode,
[SpanCategory.Start]: CommonNode,
[SpanCategory.Agent]: CommonNode,
[SpanCategory.LLMCall]: CommonNode,
[SpanCategory.Workflow]: CommonNode,
[SpanCategory.WorkflowStart]: CommonNode,
[SpanCategory.WorkflowEnd]: CommonNode,
[SpanCategory.Plugin]: CommonNode,
[SpanCategory.Knowledge]: CommonNode,
[SpanCategory.Code]: CommonNode,
[SpanCategory.Condition]: CommonNode,
[SpanCategory.Card]: CommonNode,
[SpanCategory.Message]: CommonNode,
[SpanCategory.Loop]: CommonNode,
[SpanCategory.LongTermMemory]: CommonNode,
};
export const CUSTOM_EDGES: Record<NodeEdgeCategory, React.FC<EdgeProps>> = {
[NodeEdgeCategory.Common]: CommonEdge,
};

View File

@@ -0,0 +1,219 @@
/*
* 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 {
IconCozDatabaseFill,
IconCozLongTermMemory,
} from '@coze-arch/coze-design/icons';
import {
IconSpanAgent,
IconSpanBMBatch,
IconSpanBMConnector,
IconSpanBMParallel,
IconSpanCard,
IconSpanCode,
IconSpanCondition,
IconSpanKnowledge,
IconSpanLLMCall,
IconSpanMessage,
IconSpanPluginTool,
IconSpanUnknown,
IconSpanHook,
IconSpanVar,
IconSpanWorkflow,
IconSpanWorkflowEnd,
IconSpanWorkflowStart,
} from '@coze-arch/bot-icons';
import { SpanCategory, SpanType } from '@coze-arch/bot-api/ob_query_api';
import { ResourceType } from '@coze-arch/bot-api/dp_manage_api';
import {
type TopologicalStatusData,
type TopologicalLayoutBizData,
type TopologicalLayoutCommonData,
} from '../typing';
export const TOPOLOGY_COMMON_NODE_TEXT_FONT = '14px SF Pro Display';
export const TOPOLOGY_COMMON_NODE_TEXT_DEFAULT_WIDTH = 100;
export const TOPOLOGY_COMMON_NODE_TEXT_HEIGHT = 24;
export const TOPOLOGY_COMMON_NODE_TEXT_MAX_WIDTH = 200;
export const TOPOLOGY_COMMON_NODE_TEXT_ADDITIONAL_WIDTH = 62;
export const TOPOLOGY_COMMON_EDGE_OFFSET_WIDTH = 12;
export const TOPOLOGY_DEFAULT_NODE_ICON = <IconSpanUnknown />;
export enum NodeEdgeCategory {
Common = 'common',
}
export enum TopologyLayoutDirection {
TB = 'TB',
LR = 'LR',
}
export const TOPOLOGY_LAYOUT_RECORD: Partial<
Record<SpanType, TopologyLayoutDirection>
> = {
[SpanType.InvokeAgent]: TopologyLayoutDirection.TB,
[SpanType.UserInput]: TopologyLayoutDirection.TB,
[SpanType.UserInputV2]: TopologyLayoutDirection.TB,
[SpanType.Workflow]: TopologyLayoutDirection.LR,
};
export const RESOURCE_TYPE_RECORD: Partial<Record<SpanType, ResourceType>> = {
[SpanType.InvokeAgent]: ResourceType.Bot,
[SpanType.UserInput]: ResourceType.Bot,
[SpanType.UserInputV2]: ResourceType.Bot,
[SpanType.Workflow]: ResourceType.Workflow,
};
export enum NodeLayoutCategory {
Common,
}
export const TOPOLOGY_LAYOUT_COMMON_MAP: Record<
NodeLayoutCategory,
TopologicalLayoutCommonData
> = {
[NodeLayoutCategory.Common]: {
height: TOPOLOGY_COMMON_NODE_TEXT_HEIGHT,
},
};
export const TOPOLOGY_LAYOUT_BIZ_MAP: Record<
SpanCategory,
TopologicalLayoutBizData
> = {
[SpanCategory.Unknown]: {
icon: <IconSpanUnknown />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Start]: {
icon: <IconSpanWorkflowStart />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Agent]: {
icon: <IconSpanAgent />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.LLMCall]: {
icon: <IconSpanLLMCall />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Workflow]: {
icon: <IconSpanWorkflow />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.WorkflowStart]: {
icon: <IconSpanWorkflowStart />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.WorkflowEnd]: {
icon: <IconSpanWorkflowEnd />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Plugin]: {
icon: <IconSpanPluginTool />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Knowledge]: {
icon: <IconSpanKnowledge />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Code]: {
icon: <IconSpanCode />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Condition]: {
icon: <IconSpanCondition />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Card]: {
icon: <IconSpanCard />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Message]: {
icon: <IconSpanMessage />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Variable]: {
icon: <IconSpanVar />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Database]: {
icon: <IconCozDatabaseFill />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.LongTermMemory]: {
icon: <IconCozLongTermMemory />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Hook]: {
icon: <IconSpanHook />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Batch]: {
icon: <IconSpanBMBatch />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Loop]: {
icon: <IconSpanBMBatch />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Parallel]: {
icon: <IconSpanBMParallel />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Script]: {
icon: <IconSpanCode />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.CallFlow]: {
icon: <IconSpanWorkflow />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
[SpanCategory.Connector]: {
icon: <IconSpanBMConnector />,
...TOPOLOGY_LAYOUT_COMMON_MAP[NodeLayoutCategory.Common],
},
};
export enum TopologyEdgeStatus {
STATIC,
DYNAMIC,
ERROR,
}
export const TOPOLOGY_EDGE_STATUS_MAP: Record<
TopologyEdgeStatus,
TopologicalStatusData
> = {
[TopologyEdgeStatus.STATIC]: {
edgeColor: '#C8C8CA',
nodeClassName: 'common-node-container_static',
},
[TopologyEdgeStatus.DYNAMIC]: {
edgeColor: '#3EC254',
nodeClassName: 'common-node-container_dynamic',
},
[TopologyEdgeStatus.ERROR]: {
edgeColor: '#FF441E',
nodeClassName: 'common-node-container_error',
},
};

View File

@@ -0,0 +1,19 @@
.batch-edge-info-container {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 4px;
font-size: 14px;
color: rgb(29 28 35 / 80%);
background: #F7F7FA;
border-radius: 4px;
}
.batch-edge-info-container_error {
font-weight: 600;
color: #FF441E;
background: #FFF3EE;
}

View File

@@ -0,0 +1,171 @@
/*
* 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 EdgeProps,
BaseEdge,
getBezierPath,
Position,
EdgeLabelRenderer,
} from 'reactflow';
import { useMemo } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/bot-semi';
import { SpanStatus } from '@coze-arch/bot-api/ob_query_api';
import { getTopologyItemStatus } from '../util';
import {
type TopologicalBatchNodeExecutionInfo,
type EdgeData,
} from '../typing';
import { TOPOLOGY_EDGE_STATUS_MAP } from '../constant';
import { type SpanNode } from '../../../utils/cspan-graph';
import { checkIsBatchBasicCSpan } from '../../../utils/cspan';
import { type CSPanBatch } from '../../../typings/cspan';
import s from './common.module.less';
const isVerticalEdge = (position: Position) =>
position === Position.Top || position === Position.Bottom;
const getBatchNodeExecutionInfo = (
spanNode?: SpanNode,
): TopologicalBatchNodeExecutionInfo => {
const batchNodeExecutionInfo: TopologicalBatchNodeExecutionInfo = {
isBatch: false,
isError: false,
errorNumber: 0,
totalNumber: 0,
};
if (!spanNode || !checkIsBatchBasicCSpan(spanNode)) {
return batchNodeExecutionInfo;
}
const { spans, status: batchNodeStatus } = spanNode as CSPanBatch;
batchNodeExecutionInfo.isBatch = true;
batchNodeExecutionInfo.isError = batchNodeStatus === SpanStatus.Error;
spans.forEach(span => {
const { status } = span;
batchNodeExecutionInfo.totalNumber++;
if (status === SpanStatus.Error) {
batchNodeExecutionInfo.errorNumber++;
}
});
return batchNodeExecutionInfo;
};
interface BatchEdgeInfoProps {
batchNodeExecutionInfo: TopologicalBatchNodeExecutionInfo;
}
const BatchEdgeInfo = (props: BatchEdgeInfoProps) => {
const { batchNodeExecutionInfo } = props;
const { isError, totalNumber, errorNumber } = batchNodeExecutionInfo;
return (
<div
className={classNames(
s['batch-edge-info-container'],
isError && s['batch-edge-info-container_error'],
)}
>
{!isError || totalNumber === errorNumber ? (
<>{totalNumber}</>
) : (
<Tooltip
content={I18n.t('analytic_query_detail_topology_tooltip', {
errorCount: errorNumber,
callCount: totalNumber,
})}
>
{errorNumber}
<span style={{ color: '#1D1C23' }}>
<span style={{ margin: '0 3px' }}>/</span>
{totalNumber}
</span>
</Tooltip>
)}
</div>
);
};
export const CommonEdge = (props: EdgeProps<EdgeData>) => {
const {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
markerEnd,
data,
} = props;
const batchNodeExecutionInfo = useMemo(
() => getBatchNodeExecutionInfo(data?.tailDynamicSpanNode),
[data?.tailDynamicSpanNode],
);
const topologyEdgeStatus = getTopologyItemStatus(data?.tailDynamicSpanNode);
// vertical类型线段布局时采用节点位置进行定位从而使线段起点和终点定位在节点开始位置
const adaptedSourceX = isVerticalEdge(sourcePosition)
? data?.layoutInfo?.customSourceX ?? sourceX
: sourceX;
const adaptedTargetX = isVerticalEdge(targetPosition)
? data?.layoutInfo?.customTargetX ?? targetX
: targetX;
const [edgePath, labelX, labelY] = getBezierPath({
sourceX: adaptedSourceX,
sourceY,
targetX: adaptedTargetX,
targetY,
sourcePosition,
targetPosition,
});
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
strokeWidth: 2,
stroke: TOPOLOGY_EDGE_STATUS_MAP[topologyEdgeStatus].edgeColor,
}}
/>
{batchNodeExecutionInfo.isBatch && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
>
<BatchEdgeInfo batchNodeExecutionInfo={batchNodeExecutionInfo} />
</div>
</EdgeLabelRenderer>
)}
</>
);
};

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 { CommonEdge } from './common';

View File

@@ -0,0 +1,47 @@
.common-node {
width: auto;
height: 24px;
.common-node-container {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
height: 100%;
height: 24px;
padding: 2px 4px;
font-size: 14px;
color: rgb(29 28 35 / 80%);
border-radius: 4px;
.common-node-container-text {
max-width: 200px;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
}
.common-node-container_static {
background: none;
}
.common-node-container_dynamic {
background: #EDF9EE;
}
.common-node-container_error {
font-weight: 600;
color: #FF441E;
background: #FFF3EE;
}
:global(.react-flow__handle) {
visibility: hidden;
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Handle,
type NodeProps,
Position,
useUpdateNodeInternals,
} from 'reactflow';
import classNames from 'classnames';
import { useUpdateEffect } from 'ahooks';
import { Typography } from '@coze-arch/bot-semi';
import { SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { getTopologyItemStatus } from '../util';
import { type NodeData } from '../typing';
import {
TOPOLOGY_EDGE_STATUS_MAP,
TopologyEdgeStatus,
TopologyLayoutDirection,
} from '../constant';
import s from './common.module.less';
const { Text } = Typography;
export const CommonNode = (props: NodeProps<NodeData>) => {
const {
id,
type,
data: { name, icon, layoutDirection, dynamicSpanNode },
} = props;
// 特化逻辑动态tracing中没有workflow_start节点topo中workflow_start节点默认高亮
const topologyNodeStatus =
Number(type) === SpanCategory.WorkflowStart
? TopologyEdgeStatus.DYNAMIC
: getTopologyItemStatus(dynamicSpanNode);
const updateNodeInternals = useUpdateNodeInternals();
useUpdateEffect(() => {
updateNodeInternals(id);
}, [layoutDirection]);
return (
<div className={s['common-node']}>
<Handle
type="target"
position={
layoutDirection === TopologyLayoutDirection.LR
? Position.Left
: Position.Top
}
/>
<Handle
type="source"
position={
layoutDirection === TopologyLayoutDirection.LR
? Position.Right
: Position.Bottom
}
/>
<div
className={classNames(
s['common-node-container'],
s[TOPOLOGY_EDGE_STATUS_MAP[topologyNodeStatus].nodeClassName],
)}
>
{icon}
<Text
className={s['common-node-container-text']}
ellipsis={{ showTooltip: true }}
>
{name}
</Text>
</div>
</div>
);
};

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 { CommonNode } from './common';

View File

@@ -0,0 +1,333 @@
/*
* 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 { useReactFlow, useStoreApi } from 'reactflow';
import { type RefObject, useEffect, useRef, useState } from 'react';
import { useAsyncEffect, useSize, useThrottle } from 'ahooks';
import {
Env,
type ResourceType,
type GetTopoInfoReq,
type TopoInfo,
TopoType,
} from '@coze-arch/bot-api/dp_manage_api';
import { dpManageApi } from '@coze-arch/bot-api';
import { buildTraceTree } from '../../utils/cspan-graph';
import { DataSourceTypeEnum } from '../../typings/graph';
import {
type CSpanAttrUserInput,
type CSpanAttrInvokeAgent,
type CSpanAttrWorkflow,
} from '../../typings/cspan';
import {
completeDynamicTopologyInfo,
extractOriginDynamicNodeMap,
filterObjectByKeys,
findNearestTopologyRootSpanNode,
generateStaticTopologyDataMapKey,
generateTopologyMetaInfo,
getAllUpstreamTopologyNodeIds,
getLayoutedMeta,
getNodeResourceId,
getTopologyAgentRootType,
} from './util';
import {
type DynamicNodeMap,
type DynamicTopologyData,
type StaticTopologyDataCache,
type UseGenerateTopologyHookData,
type TopologicalData,
type ProcessedGetTopoInfoReq,
} from './typing';
import {
RESOURCE_TYPE_RECORD,
TOPOLOGY_LAYOUT_RECORD,
type TopologyLayoutDirection,
} from './constant';
export const useLayoutTopology = (
topologicalData: TopologicalData | undefined,
): [RefObject<HTMLDivElement>] => {
const topologyFlowDomRef = useRef<HTMLDivElement>(null);
const topologyFlowBoxSize = useSize(topologyFlowDomRef);
const throttledTopologyFlowBoxSize = useThrottle(topologyFlowBoxSize);
const { setCenter } = useReactFlow();
const store = useStoreApi();
useEffect(() => {
if (topologicalData && throttledTopologyFlowBoxSize) {
const { nodeInternals } = store.getState();
const nodes = Array.from(nodeInternals).map(([, node]) => node);
if (nodes.length > 0) {
const node = nodes[0];
const zoom = 0.7;
const { height, width } = throttledTopologyFlowBoxSize;
const x = node.position.x + (node.width || 0) / 2 + (width / 2) * zoom;
const y =
node.position.y + (node.height || 0) / 2 + (height / 2) * zoom;
setCenter(x, y, { zoom, duration: 1000 });
}
}
}, [topologicalData, setCenter, store, throttledTopologyFlowBoxSize]);
return [topologyFlowDomRef];
};
const notShowTopo = (topoInfo: TopoInfo | undefined): topoInfo is undefined => {
if (!topoInfo) {
return true;
}
if (!topoInfo.nodes) {
return true;
}
if (!topoInfo.topo_type || topoInfo.topo_type === TopoType.AgentFlow) {
return true;
}
return false;
};
export const useGenerateTopology = (
data: UseGenerateTopologyHookData,
): [boolean, TopologicalData | undefined] => {
const { botId, entityId, spaceId, dataSource, selectedSpanId } = data;
const [loading, setLoading] = useState(false);
const [topologicalData, setTopologicalData] = useState<TopologicalData>();
// 静态topo接口原始数据及计算数据缓存
const staticTopologyDataRef = useRef<Record<string, StaticTopologyDataCache>>(
{},
);
// 某个span最近的上游可查询到topo的span节点缓存
const nearestTopologyRootSpanMapRef = useRef<DynamicNodeMap>({});
const resetStatus = () => {
setTopologicalData(undefined);
setLoading(false);
};
const fetchStaticTopologyData = async (req: GetTopoInfoReq) => {
const resp = await dpManageApi.GetTopoInfo({
...req,
});
return resp.data;
};
const getOriginDynamicData = (): DynamicTopologyData | undefined => {
const { type, spanData = [] } = dataSource;
if (type === DataSourceTypeEnum.SpanData) {
const traceTree = buildTraceTree(spanData, false);
return extractOriginDynamicNodeMap(traceTree, botId || entityId || '');
} else {
// TraceId类型暂不实现
return undefined;
}
};
useAsyncEffect(async () => {
try {
if (!selectedSpanId) {
resetStatus();
return;
}
setLoading(true);
/**
* Step 1
* 获取动态tracing tree数据找到当前选中节点
*/
const originDynamicData = getOriginDynamicData();
const currentSelectedSpanNode =
originDynamicData?.originDynamicNodeMap?.[selectedSpanId];
if (!originDynamicData || !currentSelectedSpanNode) {
resetStatus();
return;
}
const getNearestTopologyRootSpanNode = () => {
// 命中缓存
if (nearestTopologyRootSpanMapRef.current[selectedSpanId]) {
return nearestTopologyRootSpanMapRef.current[selectedSpanId];
}
const node = findNearestTopologyRootSpanNode(currentSelectedSpanNode);
if (!node) {
return undefined;
}
nearestTopologyRootSpanMapRef.current[selectedSpanId] = node;
return node;
};
/**
* Step 2
* 从当前节点开始向上找到最近的可绘制topo的节点并请求得到静态原始topo数据
*/
const nearestTopologyRootSpanNode = getNearestTopologyRootSpanNode() as
| CSpanAttrInvokeAgent
| CSpanAttrWorkflow
| CSpanAttrUserInput
| undefined;
if (!nearestTopologyRootSpanNode) {
resetStatus();
return;
}
const { type } = nearestTopologyRootSpanNode;
// 当前支持InvokeAgent和Workflow类型分别取bot和workflow的id以及version
const getStaticTopologyMetaData = (): Partial<
Pick<GetTopoInfoReq, 'resource_id' | 'version'>
> => {
if (
getTopologyAgentRootType().includes(nearestTopologyRootSpanNode.type)
) {
return {
resource_id: botId || entityId || '',
version: nearestTopologyRootSpanNode.extra?.bot_version,
};
} else {
const typedNearestTopologyRootSpanNode =
nearestTopologyRootSpanNode as CSpanAttrWorkflow;
return {
resource_id: typedNearestTopologyRootSpanNode.extra?.workflow_id,
version: typedNearestTopologyRootSpanNode.extra?.workflow_version,
};
}
};
const { resource_id, version } = getStaticTopologyMetaData();
if (!resource_id || !version) {
resetStatus();
return;
}
const processedGetTopoInfoReq: ProcessedGetTopoInfoReq = {
space_id: spaceId,
env: Env.Online,
resource_type: RESOURCE_TYPE_RECORD[type] as ResourceType,
resource_id: resource_id ?? '',
version: version ?? '',
};
const staticTopologyDataMapKey = generateStaticTopologyDataMapKey(
processedGetTopoInfoReq,
);
const staticTopologyDataCache = staticTopologyDataRef.current[
staticTopologyDataMapKey
] as StaticTopologyDataCache | undefined;
const topoInfo =
// 优先从缓存读取
staticTopologyDataCache?.topoInfoMap ??
(await fetchStaticTopologyData(processedGetTopoInfoReq));
if (notShowTopo(topoInfo)) {
resetStatus();
return;
}
/**
* Step 3
* 过滤出当前静态topo节点中需要展示动态调用链路的节点
*/
const topoMetaInfo =
// 优先从缓存读取
staticTopologyDataCache?.topoMetaInfo ??
generateTopologyMetaInfo(topoInfo);
const upstreamNodeMap = staticTopologyDataCache?.upstreamNodeMap ?? {};
// 判断当前节点自身是否有topo信息即当前所需要展示的topo的根节点
const isSelectedNodeTopologyRoot =
currentSelectedSpanNode.id === nearestTopologyRootSpanNode.id;
const currentSelectedSpanNodeTopoNodeId =
topoMetaInfo.nodeIdMap[
getNodeResourceId(currentSelectedSpanNode, botId || entityId || '')
];
// 如果当前节点为topo根节点那么展示所有动态节点信息
// 否则过滤出当前节点在静态topo中的所有上游节点只对这些上游节点进行展示
const currentDynamicNodeMap = isSelectedNodeTopologyRoot
? originDynamicData.dynamicNodeMap
: filterObjectByKeys(
originDynamicData.dynamicNodeMap,
[
currentSelectedSpanNodeTopoNodeId,
...getAllUpstreamTopologyNodeIds(
currentSelectedSpanNodeTopoNodeId,
topoMetaInfo.topoGraph,
upstreamNodeMap,
),
].map(nodeId => topoMetaInfo.resourceIdMap[nodeId]),
);
const layoutDirection = TOPOLOGY_LAYOUT_RECORD[
type
] as TopologyLayoutDirection;
/**
* Step 4
* 补齐动态节点信息 & 布局信息到静态topo
*/
const originalTopologicalData = completeDynamicTopologyInfo(
topoInfo,
currentDynamicNodeMap,
layoutDirection,
);
const layoutTopologicalData = getLayoutedMeta(
originalTopologicalData,
layoutDirection,
);
// 存入缓存
staticTopologyDataRef.current[staticTopologyDataMapKey] = {
topoInfoMap: topoInfo,
topoMetaInfo,
upstreamNodeMap,
};
setTopologicalData(layoutTopologicalData);
// eslint-disable-next-line @coze-arch/use-error-in-catch
} catch (e) {
resetStatus();
} finally {
setLoading(false);
}
}, [botId, entityId, spaceId, dataSource, selectedSpanId]);
useEffect(
// 销毁时清空缓存
() => () => {
staticTopologyDataRef.current = {
topoInfoMap: {},
upstreamNodeMap: {},
};
nearestTopologyRootSpanMapRef.current = {};
},
[],
);
return [loading, topologicalData];
};

View File

@@ -0,0 +1,32 @@
.topology-flow {
.topology-flow-loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
:global(.semi-spin-children) {
/* stylelint-disable-next-line declaration-no-important */
background: none !important;
}
}
.topology-flow-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
.topology-flow-container-flow {
flex: 1;
}
}
}
.topology-flow_default {
width: 100%;
height: 300px;
padding: 24px;
}

View File

@@ -0,0 +1,92 @@
/*
* 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 ReactFlow, { ReactFlowProvider } from 'reactflow';
import { useMemo } from 'react';
import classNames from 'classnames';
import { Spin } from '@coze-arch/bot-semi';
import { type TopologyFlowProps } from './typing';
import 'reactflow/dist/style.css';
import { useGenerateTopology, useLayoutTopology } from './hook';
import { CUSTOM_EDGES, CUSTOM_NODES } from './constant/flow';
import s from './index.module.less';
const TopologyFlowContent = (props: TopologyFlowProps) => {
const { style, className, renderHeader, ...restProps } = props;
// 计算topo数据
const [loading, topologicalData] = useGenerateTopology({
...restProps,
});
// 每次topo数据变更后计算topo布局信息
const [topologyFlowDomRef] = useLayoutTopology(topologicalData);
// 渲染外部自定义header实现带有业务语义
const topologyHeader = useMemo(() => {
if (!renderHeader || !topologicalData) {
return null;
}
return renderHeader(topologicalData.topoType);
}, [renderHeader, topologicalData]);
return topologicalData ? (
<div
className={classNames(
s['topology-flow'],
className ?? s['topology-flow_default'],
)}
style={style}
ref={topologyFlowDomRef}
>
{loading ? (
<div className={s['topology-flow-loading']}>
<Spin />
</div>
) : (
<div className={s['topology-flow-container']}>
{topologyHeader}
<div className={s['topology-flow-container-flow']}>
<ReactFlow
// @ts-expect-error 使用number类型枚举SpanType作为自定义type可忽略报错
nodes={topologicalData.nodes}
edges={topologicalData.edges}
nodeTypes={CUSTOM_NODES}
edgeTypes={CUSTOM_EDGES}
proOptions={{
hideAttribution: true,
}}
nodesDraggable={false}
nodesConnectable={false}
/>
</div>
</div>
)}
</div>
) : null;
};
const TopologyFlow = (props: TopologyFlowProps) => (
<ReactFlowProvider>
<TopologyFlowContent {...props} />
</ReactFlowProvider>
);
export default TopologyFlow;

View File

@@ -0,0 +1,119 @@
/*
* 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 Node, type Edge } from 'reactflow';
import { type CSSProperties } from 'react';
import { type SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { type LogBizScene } from '@coze-arch/bot-api/ob_data';
import {
type TopoInfo,
type GetTopoInfoReq,
type TopoType,
} from '@coze-arch/bot-api/dp_manage_api';
import { type SpanNode } from '../../utils/cspan-graph';
import { type DataSource } from '../../typings/graph';
import { type TopologyLayoutDirection } from './constant';
export type ProcessedGetTopoInfoReq = Omit<GetTopoInfoReq, 'Base'>;
export interface TopologyFlowProps {
spaceId: string;
botId?: string;
entityId?: string;
entityType?: LogBizScene;
dataSource: DataSource;
selectedSpanId?: string;
style?: CSSProperties;
className?: string;
renderHeader?: (topologyType: TopoType) => React.ReactNode;
}
export interface UseGenerateTopologyHookData {
spaceId: string;
botId?: string;
entityId?: string;
entityType?: LogBizScene;
dataSource: DataSource;
selectedSpanId?: string;
}
// @ts-expect-error 使用number类型枚举SpanType作为自定义type可忽略报错
export type TopologicalNode = Node<NodeData, SpanCategory>;
export type TopologicalEdge = Edge<EdgeData>;
export interface NodeData {
name: string;
icon: React.ReactNode;
layoutDirection: TopologyLayoutDirection;
dynamicSpanNode?: SpanNode;
}
export interface EdgeData {
tailDynamicSpanNode?: SpanNode;
layoutInfo?: {
customSourceX: number;
customTargetX: number;
};
}
export type DynamicNodeMap = Record<string, SpanNode>;
export type DynamicEdgeMap = Record<string, SpanNode>;
export interface DynamicTopologyData {
dynamicNodeMap: DynamicNodeMap;
originDynamicNodeMap: DynamicNodeMap;
}
export interface TopologicalData {
topoType: TopoType;
nodes: TopologicalNode[];
edges: TopologicalEdge[];
}
export interface TopologicalLayoutCommonData {
height: number;
}
export interface TopologicalLayoutBizData extends TopologicalLayoutCommonData {
icon: React.ReactElement;
}
export interface TopologicalStatusData {
edgeColor: string;
nodeClassName: string;
}
export interface TopologicalBatchNodeExecutionInfo {
isBatch: boolean;
isError: boolean;
errorNumber: number;
totalNumber: number;
}
export interface TopoMetaInfo {
topoGraph: Record<string, string[]>;
resourceIdMap: Record<string, string>;
nodeIdMap: Record<string, string>;
}
export interface StaticTopologyDataCache {
topoInfoMap?: TopoInfo;
topoMetaInfo?: TopoMetaInfo;
upstreamNodeMap?: Record<string, string[]>;
}

View File

@@ -0,0 +1,497 @@
/*
* 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 { MarkerType } from 'reactflow';
import { getFlags } from '@coze-arch/bot-flags';
import {
SpanCategory,
SpanStatus,
SpanType,
} from '@coze-arch/bot-api/ob_query_api';
import {
TopoType,
type TopoInfo,
type Node as TopoNode,
} from '@coze-arch/bot-api/dp_manage_api';
import Dagre from '@dagrejs/dagre';
import {
getSpanTitle as getDynamicSpanTitle,
type SpanNode,
} from '../../utils/cspan-graph';
import {
type CSpanAttrInvokeAgent,
type CSpan,
type CSpanAttrWorkflow,
type CSpanAttrPluginTool,
type CSpanAttrKnowledge,
type CSpanAttrCondition,
type CSPanBatch,
type CSpanAttrLLMCall,
} from '../../typings/cspan';
import { rootBreakSpanId } from '../../constant';
import { spanCategoryConfigMap } from '../../config/cspan';
import {
type DynamicNodeMap,
type DynamicTopologyData,
type TopologicalData,
type TopologicalNode,
type TopologicalEdge,
type ProcessedGetTopoInfoReq,
type TopoMetaInfo,
} from './typing';
import {
NodeEdgeCategory,
TOPOLOGY_COMMON_EDGE_OFFSET_WIDTH,
TOPOLOGY_COMMON_NODE_TEXT_ADDITIONAL_WIDTH,
TOPOLOGY_COMMON_NODE_TEXT_DEFAULT_WIDTH,
TOPOLOGY_COMMON_NODE_TEXT_FONT,
TOPOLOGY_COMMON_NODE_TEXT_HEIGHT,
TOPOLOGY_COMMON_NODE_TEXT_MAX_WIDTH,
TOPOLOGY_DEFAULT_NODE_ICON,
TOPOLOGY_EDGE_STATUS_MAP,
TOPOLOGY_LAYOUT_BIZ_MAP,
TopologyEdgeStatus,
type TopologyLayoutDirection,
} from './constant';
const assignRecordIfNotExists = <T extends object>(
object: T,
key: keyof T,
value: T[keyof T],
) => {
if (!object[key]) {
object[key] = value;
}
};
export const filterObjectByKeys = <T extends object>(
object: T,
targetList: string[],
) =>
Object.keys(object)
.filter(key => targetList.includes(key))
.reduce<T>((pre, cur) => {
pre[cur] = object[cur];
return pre;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
}, {} as T);
/**
* 静态topo数据中resource_id根据span类型的不同和不同的字段映射
* @param spanNode 当前span
* @param entityId
* @returns 与静态topo所映射的id
*/
export const getNodeResourceId = (
spanNode: SpanNode,
entityId: string,
): string => {
const { category, id } = spanNode;
const workflowNodeId =
// 兼容合并后的Batch节点
(spanNode as CSPanBatch).workflow_node_id ??
(spanNode as CSpanAttrCondition).extra?.workflow_node_id;
if (workflowNodeId) {
return workflowNodeId;
}
switch (category) {
case SpanCategory.Agent:
case SpanCategory.Start:
return (spanNode as CSpanAttrInvokeAgent).extra?.agent_id || entityId;
case SpanCategory.Workflow:
return (spanNode as CSpanAttrWorkflow).extra?.workflow_id || id;
case SpanCategory.Plugin:
return (spanNode as CSpanAttrPluginTool).extra?.plugin_id || id;
case SpanCategory.Knowledge:
return (spanNode as CSpanAttrKnowledge).extra?.knowledge_id || id;
case SpanCategory.LLMCall:
return (spanNode as CSpanAttrLLMCall).extra?.model || id;
default:
return id;
}
};
export const generateStaticTopologyDataMapKey = (
info: ProcessedGetTopoInfoReq,
) => {
const { space_id, resource_id, version, env, resource_type } = info;
return `${space_id}-${resource_id}-${version}-${env}-${resource_type}`;
};
// 单 agent 下 trace 里将去除 SpanType.InvokeAgent 节点topology root 可能为 SpanType.UserInput
export const getTopologyAgentRootType = () => {
const FLAGS = getFlags();
return FLAGS['bot.devops.use_user_input_as_agent']
? [SpanType.InvokeAgent, SpanType.UserInput, SpanType.UserInputV2]
: [SpanType.InvokeAgent];
};
export const isTopologyRootSpan = (span: CSpan) => {
// 只有基础工作流展示拓扑
if (span.type === SpanType.Workflow) {
return (span as CSpanAttrWorkflow).extra?.workflow_schema_type === 1;
}
return [...getTopologyAgentRootType()].includes(span.type);
};
export const extractOriginDynamicNodeMap = (
spanNode: SpanNode,
entityId: string,
): DynamicTopologyData => {
const dynamicNodeMap: DynamicNodeMap = {};
const originDynamicNodeMap: DynamicNodeMap = {};
const queue = [spanNode];
while (queue.length > 0) {
const currentNode = queue.shift() as SpanNode;
// 过滤掉broken节点
if (currentNode.id === rootBreakSpanId) {
continue;
}
originDynamicNodeMap[currentNode.id] = currentNode;
assignRecordIfNotExists(
dynamicNodeMap,
getNodeResourceId(currentNode, entityId),
currentNode,
);
for (const childNode of currentNode.children ?? []) {
queue.push(childNode);
}
}
return {
dynamicNodeMap,
originDynamicNodeMap,
};
};
export const findNearestTopologyRootSpanNode = (
spanNode?: SpanNode,
): SpanNode | undefined => {
let currentSpanNode = spanNode;
while (currentSpanNode) {
if (isTopologyRootSpan(currentSpanNode)) {
return currentSpanNode;
}
currentSpanNode = currentSpanNode.parent;
}
return undefined;
};
function findUserInputRootNode(dynamicNodeMap: DynamicNodeMap) {
return Object.values(dynamicNodeMap).find(
item =>
item.type === SpanType.UserInput || item.type === SpanType.UserInputV2,
);
}
/**
* 将动态节点填充给对应的静态节点和连线,补全其动态运行信息
* @param staticTopoInfo
* @param dynamicNodeMap 当前所需要展示的动态节点map
* @param layoutDirection
* @returns
*/
export const completeDynamicTopologyInfo = (
staticTopoInfo: TopoInfo,
dynamicNodeMap: DynamicNodeMap,
layoutDirection: TopologyLayoutDirection,
): TopologicalData => {
const FLAGS = getFlags();
const {
nodes: staticNodes = [],
edges: staticEdges = [],
topo_type = TopoType.AgentFlow,
} = staticTopoInfo;
const nodeInfoMap: Record<
string,
{
node: TopoNode;
resourceId: string;
}
> = {};
const nodes: TopologicalNode[] = staticNodes.map(item => {
const {
node_id = '',
resource_id = '',
resource_kind,
resource_name = '',
} = item;
nodeInfoMap[node_id] = {
node: item,
resourceId: resource_id,
};
const typedResourceKind: SpanCategory =
resource_kind && resource_kind in SpanCategory
? Number(resource_kind)
: SpanCategory.Unknown;
let dynamicSpanNode = dynamicNodeMap[resource_id] as SpanNode | undefined;
// 特化逻辑单agent场景下节点信息将不包含agent节点需要使用userInput替换
if (
typedResourceKind === SpanCategory.Agent &&
!dynamicSpanNode &&
FLAGS['bot.devops.use_user_input_as_agent']
) {
dynamicSpanNode = findUserInputRootNode(dynamicNodeMap) as
| SpanNode
| undefined;
}
let title = '';
if (dynamicSpanNode) {
title = getDynamicSpanTitle(dynamicSpanNode);
} else {
title = getStaticSpanTitle(typedResourceKind, resource_name);
}
return {
id: node_id,
type: typedResourceKind,
position: {
x: 0,
y: 0,
},
data: {
name: title,
icon:
TOPOLOGY_LAYOUT_BIZ_MAP[typedResourceKind]?.icon ??
TOPOLOGY_DEFAULT_NODE_ICON,
dynamicSpanNode,
layoutDirection,
},
};
});
const edges: TopologicalEdge[] = staticEdges.map(item => {
const { edge_id = '', source_node_id = '', target_node_id = '' } = item;
const sourceNodeInfo = nodeInfoMap[source_node_id];
// 特化逻辑动态tracing中没有workflow_start节点默认将其下游的动态节点填入
const isWorkflowStartNode =
Number(sourceNodeInfo?.node?.resource_kind) ===
SpanCategory.WorkflowStart;
let sourceNode: SpanNode | undefined =
dynamicNodeMap[sourceNodeInfo?.resourceId];
const targetNode: SpanNode | undefined =
dynamicNodeMap[nodeInfoMap[target_node_id]?.resourceId];
// 特化逻辑单agent场景下节点信息将不包含agent节点需要使用userInput替换
if (
sourceNodeInfo.node.resource_kind === SpanCategory.Agent &&
!sourceNode &&
FLAGS['bot.devops.use_user_input_as_agent']
) {
sourceNode = findUserInputRootNode(dynamicNodeMap) as
| SpanNode
| undefined;
}
let tailDynamicSpanNode: SpanNode | undefined;
if (isWorkflowStartNode || (sourceNode && targetNode)) {
tailDynamicSpanNode = targetNode;
}
const topologyEdgeStatus = getTopologyItemStatus(tailDynamicSpanNode);
return {
id: edge_id,
source: source_node_id,
target: target_node_id,
type: NodeEdgeCategory.Common,
markerEnd: {
type: MarkerType.Arrow,
color: TOPOLOGY_EDGE_STATUS_MAP[topologyEdgeStatus].edgeColor,
height: 17,
width: 17,
},
data: {
tailDynamicSpanNode,
},
};
});
return {
nodes,
edges,
topoType: topo_type,
};
};
const measureTextCanvas = document.createElement('canvas');
const measureTextContext = measureTextCanvas.getContext('2d');
const getTextWidth = (text: string) => {
if (!measureTextContext) {
return TOPOLOGY_COMMON_NODE_TEXT_DEFAULT_WIDTH;
}
measureTextContext.font = TOPOLOGY_COMMON_NODE_TEXT_FONT;
return (
Math.min(
Math.round(measureTextContext.measureText(text).width),
TOPOLOGY_COMMON_NODE_TEXT_MAX_WIDTH,
) + TOPOLOGY_COMMON_NODE_TEXT_ADDITIONAL_WIDTH
);
};
const getStaticSpanTitle = (category: SpanCategory, name: string) => {
const typeName = spanCategoryConfigMap[category]?.label ?? '';
if (name && name !== typeName) {
return `${typeName} ${name}`;
} else {
return typeName;
}
};
/**
* 进行对原始topo数据的布局和样式处理
* @param originTopologicalData
* @param layoutDirection
* @returns
*/
export const getLayoutedMeta = (
originTopologicalData: TopologicalData,
layoutDirection: TopologyLayoutDirection,
): TopologicalData => {
const graphInstance = new Dagre.graphlib.Graph().setDefaultEdgeLabel(
() => ({}),
);
graphInstance.setGraph({
rankdir: layoutDirection,
align: 'UL',
});
const { edges, nodes, topoType } = originTopologicalData;
edges.forEach(edge => graphInstance.setEdge(edge.source, edge.target));
nodes.forEach(node => {
const { type = SpanCategory.Unknown, data } = node;
const { name } = data;
graphInstance.setNode(node.id, {
label: name,
height:
TOPOLOGY_LAYOUT_BIZ_MAP[type]?.height ??
TOPOLOGY_COMMON_NODE_TEXT_HEIGHT,
width: getTextWidth(name),
});
});
Dagre.layout(graphInstance);
// 采集节点的定位信息用于vertical类型的连线绘制时进行定位
const nodeXAxisMap: Record<string, number> = {};
const layoutNodes: TopologicalNode[] = nodes.map(node => {
const { x, y } = graphInstance.node(node.id);
nodeXAxisMap[node.id] = x;
return { ...node, position: { x, y } };
});
const layoutEdges: TopologicalEdge[] = edges.map(edge => {
const { source, target } = edge;
return {
...edge,
data: {
...edge.data,
layoutInfo: {
customSourceX:
nodeXAxisMap[source] + TOPOLOGY_COMMON_EDGE_OFFSET_WIDTH,
customTargetX:
nodeXAxisMap[target] + TOPOLOGY_COMMON_EDGE_OFFSET_WIDTH,
},
},
};
});
return {
topoType,
nodes: layoutNodes,
edges: layoutEdges,
};
};
export const getTopologyItemStatus = (spanNode?: SpanNode) => {
if (!spanNode) {
return TopologyEdgeStatus.STATIC;
}
if (spanNode.status === SpanStatus.Error) {
return TopologyEdgeStatus.ERROR;
}
return TopologyEdgeStatus.DYNAMIC;
};
/**
* 使用静态topo数据生成记录每个节点的上游节点的graph以及id map
* @param topoInfo
* @returns graph和map的meta信息
*/
export const generateTopologyMetaInfo = (topoInfo: TopoInfo): TopoMetaInfo => {
const { nodes = [], edges = [] } = topoInfo;
const resourceIdMap: Record<string, string> = {};
const nodeIdMap: Record<string, string> = {};
const topoGraph: Record<string, string[]> = {};
for (const { node_id = '', resource_id = '' } of nodes) {
resourceIdMap[node_id] = resource_id;
nodeIdMap[resource_id] = node_id;
}
for (const { source_node_id = '', target_node_id = '' } of edges) {
if (!topoGraph[target_node_id]) {
topoGraph[target_node_id] = [];
}
topoGraph[target_node_id].push(source_node_id);
}
return {
resourceIdMap,
nodeIdMap,
topoGraph,
};
};
/**
* 查询静态topo图中某个节点的所有上游节点使用DP降低复杂度
* @param selectedNodeId 当前span id
* @param topoGraph 存有记录每个节点的上游节点的graph
* @param upstreamNodeMap 存有记录某个节点所有上游节点的map
* @returns 上游所有节点的id list
*/
export const getAllUpstreamTopologyNodeIds = (
selectedNodeId: string,
topoGraph: Record<string, string[]>,
upstreamNodeMap: Record<string, string[]>,
) => {
if (upstreamNodeMap[selectedNodeId]) {
return upstreamNodeMap[selectedNodeId];
}
const upstreamNodeIds = topoGraph[selectedNodeId] ?? [];
const allUpstreamNodeIds: string[] = [
...upstreamNodeIds,
...upstreamNodeIds.flatMap(nodeId =>
getAllUpstreamTopologyNodeIds(nodeId, topoGraph, upstreamNodeMap),
),
];
upstreamNodeMap[selectedNodeId] = allUpstreamNodeIds;
return allUpstreamNodeIds;
};

View File

@@ -0,0 +1,292 @@
/*
* 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 { SpanStatus, SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { type LabelStyle, type RectStyle } from '../flamethread';
import { type TraceFlamethreadProps } from './typing';
type DefaultProps = Pick<
TraceFlamethreadProps,
'rectStyle' | 'labelStyle' | 'rowHeight' | 'globalStyle' | 'datazoomDecimals'
>;
export const defaultProps: DefaultProps = {
labelStyle: {
position: 'inside-left',
fontSize: 12,
fill: '#1D1C23CC',
},
rowHeight: 50,
globalStyle: {},
datazoomDecimals: 1,
};
interface SpanCategoryConfig {
[spanCategory: number]:
| {
rectStyle?: RectStyle;
labelStyle?: LabelStyle;
name?: string;
}
| undefined;
}
export const spanCategoryConfig: SpanCategoryConfig = {
[SpanCategory.Unknown]: {
rectStyle: {
normal: {
fill: '#F7F7FA',
},
hover: {
fill: '#F0F0F5',
},
select: {
fill: '#C6C6CD',
},
},
},
[SpanCategory.Start]: {
rectStyle: {
normal: {
fill: '#F1F2FD',
},
hover: {
fill: '#D9DCFA',
},
select: {
fill: '#B4BAF6',
},
},
labelStyle: {},
name: 'start',
},
[SpanCategory.Agent]: {
rectStyle: {
normal: {
fill: '#F6EFFC',
},
hover: {
fill: '#E9D6F9',
},
select: {
fill: '#D1AEF4',
},
},
name: 'invoke agent',
},
[SpanCategory.LLMCall]: {
rectStyle: {
normal: {
fill: '#F7F7FA',
},
hover: {
fill: '#F0F0F5',
},
select: {
fill: '#C6C6CD',
},
},
name: 'invoke llm',
},
[SpanCategory.Workflow]: {
rectStyle: {
normal: {
fill: '#EDF9EE',
},
hover: {
fill: '#D2F3D5',
},
select: {
fill: '#CFECAC',
},
},
name: 'invoke workflow',
},
[SpanCategory.WorkflowStart]: {
rectStyle: {
normal: {
fill: '#EDF9EE',
},
hover: {
fill: '#D2F3D5',
},
select: {
fill: '#CFECAC',
},
},
},
[SpanCategory.WorkflowEnd]: {
rectStyle: {
normal: {
fill: '#EDF9EE',
},
hover: {
fill: '#D2F3D5',
},
select: {
fill: '#CFECAC',
},
},
},
[SpanCategory.Plugin]: {
rectStyle: {
normal: {
fill: '#F1F2FD',
},
hover: {
fill: '#D9DCFA',
},
select: {
fill: '#B4BAF6',
},
},
name: 'invoke plugin',
},
[SpanCategory.Knowledge]: {
rectStyle: {
normal: {
fill: '#FFEEEF',
},
hover: {
fill: '#FFD2D7',
},
select: {
fill: '#FFA5B2',
},
},
name: 'invoke knowledage',
},
[SpanCategory.Code]: {
rectStyle: {
normal: {
fill: '#E5F8F7',
},
hover: {
fill: '#C1F2EF',
},
select: {
fill: '#89E5E0',
},
},
name: 'execute code',
},
[SpanCategory.Condition]: {
rectStyle: {
normal: {
fill: '#FFFAEB',
},
hover: {
fill: '#FFF1CC',
},
select: {
fill: '#FFDF99',
},
},
name: 'if condition',
},
[SpanCategory.Card]: {
rectStyle: {
normal: {
fill: '#FFFAEB',
},
hover: {
fill: '#FFF1CC',
},
select: {
fill: '#FFDF99',
},
},
name: 'card',
},
[SpanCategory.Message]: {
rectStyle: {
normal: {
fill: '#FFFAEB',
},
hover: {
fill: '#FFF1CC',
},
select: {
fill: '#FFDF99',
},
},
name: 'message',
},
};
interface SpanStatusConfig {
[spanStatus: string]: {
tooltip?: {
fill?: string;
};
rectStyle?: RectStyle;
};
}
export const spanStatusConfig: SpanStatusConfig = {
[SpanStatus.Success]: {
tooltip: {
fill: '#3EC254',
},
rectStyle: {
normal: {
stroke: '#1D1C2314',
},
hover: {},
select: {},
},
},
[SpanStatus.Error]: {
tooltip: {
fill: '#FF441E',
},
rectStyle: {
normal: {
stroke: '#1D1C2314',
fill: '#FFF3EE',
},
hover: {
fill: '#FFE0D2',
},
select: {
fill: '#FFBDA5',
},
},
},
[SpanStatus.Broken]: {
tooltip: {
fill: '#FF9600',
},
},
[SpanStatus.Unknown]: {
tooltip: {
fill: '#6B6B75',
},
},
};
export const tooltipStyle = {
fill: '#212629',
shape: {
symbolType: 'square',
fill: '#212629',
size: 5,
},
};

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 { useEffect, type FC, useMemo, useState, useCallback } from 'react';
import { type IElement } from '@visactor/vgrammar';
import { I18n } from '@coze-arch/i18n';
import Flamethread, {
type LabelText,
type RectNode,
type RectStyle,
type Tooltip,
} from '../flamethread';
import {
getSpanDataByTraceId,
getSpanTitle,
getStatusLabel,
} from '../../utils/cspan-graph';
import { getTokens } from '../../utils/cspan';
import { DataSourceTypeEnum } from '../../typings/graph';
import { type CSpan } from '../../typings/cspan';
import { spanData2flamethreadData } from './util';
import { type TraceFlamethreadProps } from './typing';
import { defaultProps, spanStatusConfig, tooltipStyle } from './config';
const TraceFlamethread: FC<TraceFlamethreadProps> = props => {
const [flamethreadData, setFlamethreadData] = useState<RectNode[]>([]);
const {
dataSource: { type: dataType, spanData, traceId },
rectStyle: _rectStyle,
labelStyle: _labelStyle,
globalStyle: _globalStyle,
visibleColumnCount,
datazoomDecimals = defaultProps.datazoomDecimals,
axisLabelSuffix,
selectedSpanId,
spanTypeConfigMap,
spanStatusConfigMap,
disableViewScroll,
enableAutoFit,
onClick,
} = props;
// 初始化flamethreadData
useEffect(() => {
if (dataType === DataSourceTypeEnum.SpanData && spanData) {
if (spanData?.length === 0 && flamethreadData.length === 0) {
return;
}
const rectNodes = spanData2flamethreadData(spanData);
setFlamethreadData(rectNodes);
} else if (dataType === DataSourceTypeEnum.TraceId && traceId) {
const spans = getSpanDataByTraceId(traceId);
const rectNodes = spanData2flamethreadData(spans);
setFlamethreadData(rectNodes);
}
}, [dataType, spanData, traceId]);
const rectStyle = useMemo((): RectStyle => {
const defaultRectStyle = defaultProps.rectStyle;
return {
normal: Object.assign({}, defaultRectStyle?.normal, _rectStyle?.normal),
hover: Object.assign({}, defaultRectStyle?.hover, _rectStyle?.hover),
select: Object.assign({}, defaultRectStyle?.select, _rectStyle?.select),
};
}, [_rectStyle]);
const labelStyle = useMemo(
() => Object.assign({}, _labelStyle, defaultProps.labelStyle),
[_labelStyle],
);
const globalStyle = useMemo(
() => Object.assign({}, _globalStyle, defaultProps.globalStyle),
[_globalStyle],
);
const tooltip: Tooltip = useMemo(
() => ({
title: {
value: (datum: RectNode, element: IElement, params) => ({}),
},
content: (datum: RectNode, element: IElement, params) => {
const { span } = (datum.extra ?? {}) as { span: CSpan };
if (!span) {
return [];
}
const { status, latency } = span;
const statusConfig = spanStatusConfig[status];
const tips = [
{
key: I18n.t('analytic_query_status'),
value: getStatusLabel(span, spanStatusConfigMap),
},
{
key: I18n.t('analytic_query_latency'),
value: latency ? `${latency}ms` : '-',
},
];
const { input_tokens: inputTokens, output_tokens: outputTokens } =
getTokens(span);
if (inputTokens !== undefined && outputTokens !== undefined) {
tips.push({
key: I18n.t('analytic_query_tokens'),
value: `${inputTokens + outputTokens}`,
});
}
return tips.map(({ key, value }) => ({
key: {
text: key,
fill: tooltipStyle.fill,
},
value: {
text: value ?? '',
fill:
key === I18n.t('analytic_query_status')
? statusConfig?.tooltip?.fill
: tooltipStyle.fill,
},
shape: tooltipStyle.shape,
}));
},
}),
[spanStatusConfigMap],
);
const labelText: LabelText = useCallback(
(datum: RectNode, element: IElement, params) => {
const { span } = (datum.extra ?? {}) as { span: CSpan };
return getSpanTitle(span, spanTypeConfigMap);
},
[spanTypeConfigMap],
);
return flamethreadData ? (
<Flamethread
flamethreadData={flamethreadData}
tooltip={tooltip}
rectStyle={rectStyle}
labelStyle={labelStyle}
globalStyle={globalStyle}
labelText={labelText}
datazoomDecimals={datazoomDecimals}
visibleColumnCount={visibleColumnCount}
axisLabelSuffix={axisLabelSuffix}
selectedKey={selectedSpanId}
disableViewScroll={disableViewScroll}
enableAutoFit={enableAutoFit}
onClick={onClick}
/>
) : null;
};
export default TraceFlamethread;

View File

@@ -0,0 +1,41 @@
/*
* 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 FlamethreadProps } from '../flamethread';
import { type DataSource } from '../../typings/graph';
import {
type SpanStatusConfigMap,
type SpanTypeConfigMap,
} from '../../typings/config';
export type TraceFlamethreadProps = {
dataSource: DataSource;
selectedSpanId?: string;
spanTypeConfigMap?: SpanTypeConfigMap;
spanStatusConfigMap?: SpanStatusConfigMap;
} & Pick<
FlamethreadProps,
| 'rectStyle'
| 'labelStyle'
| 'globalStyle'
| 'rowHeight'
| 'visibleColumnCount'
| 'datazoomDecimals'
| 'axisLabelSuffix'
| 'disableViewScroll'
| 'enableAutoFit'
| 'onClick'
>;

View File

@@ -0,0 +1,111 @@
/*
* 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 { sortBy } from 'lodash-es';
import { SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { type RectNode, type RectStyle } from '../flamethread';
import {
buildCallTrees,
getBreakSpans,
getRootSpan,
type SpanNode,
} from '../../utils/cspan-graph';
import { isVisibleSpan } from '../../utils/cspan';
import { type CSpan } from '../../typings/cspan';
import { spanCategoryConfig, spanStatusConfig } from './config';
const genRectStyle = (span: CSpan): RectStyle => {
const { status, category = SpanCategory.Unknown } = span;
const categoryRectStyle = spanCategoryConfig[category]?.rectStyle;
const statusRectStyle = spanStatusConfig[status]?.rectStyle;
return {
normal: Object.assign(
{},
categoryRectStyle?.normal,
statusRectStyle?.normal,
),
hover: Object.assign({}, categoryRectStyle?.hover, statusRectStyle?.hover),
select: Object.assign(
{},
categoryRectStyle?.select,
statusRectStyle?.select,
),
};
};
const genRectNode = (info: {
span: CSpan;
startSpan: CSpan;
rowNo: number;
}): RectNode => {
const { span, startSpan, rowNo } = info;
const start = span.start_time - startSpan.start_time;
return {
key: span.id,
rowNo,
start,
end: start + span.latency,
rectStyle: genRectStyle(span),
extra: {
span,
},
};
};
export const spanData2flamethreadData = (spanData: CSpan[]): RectNode[] => {
// 1. 根据spans组装call trees
const callTrees = buildCallTrees(spanData);
// 2. 生成tartSpan
const startSpan: SpanNode = getRootSpan(callTrees, false);
// 3. 获取 break节点(非start的根节点都是breakSpan)
const breakSpans: SpanNode[] = getBreakSpans(callTrees, false);
let rstSpans: SpanNode[] = [];
// 前序搜索,确保父节点在前
const walk = (spans: SpanNode[]) => {
rstSpans = rstSpans.concat(spans);
spans.forEach(span => {
if (span.children) {
walk(span.children);
}
});
};
if (startSpan.children) {
walk(startSpan.children);
}
walk(breakSpans);
// 过滤掉不显示的span节点
rstSpans = rstSpans.filter(span => isVisibleSpan(span));
// 按start_time稳定排序
const sortedSpans = sortBy(rstSpans, o => o.start_time);
// 添加跟节点
sortedSpans.unshift(startSpan);
const rectNodes: RectNode[] = [];
sortedSpans.forEach((span, index) => {
rectNodes.push(genRectNode({ span, startSpan, rowNo: index }));
});
return rectNodes;
};

View File

@@ -0,0 +1,185 @@
/*
* 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 ReactNode } from 'react';
import {
IconSpanAgent,
IconSpanCard,
IconSpanCode,
IconSpanCondition,
IconSpanKnowledge,
IconSpanVar,
IconSpanLLMCall,
IconSpanHook,
IconSpanMessage,
IconSpanPluginTool,
IconSpanUnknown,
IconSpanWorkflow,
IconSpanWorkflowEnd,
IconSpanWorkflowStart,
IconSpanBMConnector,
IconSpanBMParallel,
IconSpanBMBatch,
} from '@coze-arch/bot-icons';
import { SpanStatus, SpanCategory } from '@coze-arch/bot-api/ob_query_api';
import { type LineStyle } from '../tree';
import { type TraceTreeProps } from './typing';
type DefaultProps = Pick<TraceTreeProps, 'lineStyle' | 'globalStyle'>;
export const defaultProps: DefaultProps = {
lineStyle: {
normal: {
stroke: '#C6C6CD',
strokeWidth: 1,
},
hover: {
stroke: '#C6C6CD',
strokeWidth: 2,
},
select: {
stroke: '#C6C6CD',
strokeWidth: 2,
},
},
};
type SpanCategoryConfig = Record<
number,
| {
icon: ReactNode;
title: string;
}
| undefined
>;
export const spanCategoryConfig: SpanCategoryConfig = {
[SpanCategory.Unknown]: {
icon: <IconSpanUnknown />,
title: 'Unknown',
},
[SpanCategory.Start]: {
icon: <IconSpanWorkflowStart />,
title: 'Start',
},
[SpanCategory.Agent]: {
icon: <IconSpanAgent />,
title: 'Agent',
},
[SpanCategory.LLMCall]: {
icon: <IconSpanLLMCall />,
title: 'Invoke LLM',
},
[SpanCategory.Workflow]: {
icon: <IconSpanWorkflow />,
title: 'Invoke Workflow',
},
[SpanCategory.WorkflowStart]: {
icon: <IconSpanWorkflowStart />,
title: 'Workflow Start',
},
[SpanCategory.WorkflowEnd]: {
icon: <IconSpanWorkflowEnd />,
title: 'Workflow End',
},
[SpanCategory.Plugin]: {
icon: <IconSpanPluginTool />,
title: 'Invoke Plugin',
},
[SpanCategory.Knowledge]: {
icon: <IconSpanKnowledge />,
title: 'Recall Knowledage',
},
[SpanCategory.Code]: {
icon: <IconSpanCode />,
title: 'Execute Code',
},
[SpanCategory.Condition]: {
icon: <IconSpanCondition />,
title: 'If Condition',
},
[SpanCategory.Card]: {
icon: <IconSpanCard />,
title: 'Card',
},
[SpanCategory.Message]: {
icon: <IconSpanMessage />,
title: 'Message',
},
[SpanCategory.Variable]: {
icon: <IconSpanVar />,
title: 'Variable',
},
[SpanCategory.Hook]: {
icon: <IconSpanHook />,
title: 'Hook',
},
[SpanCategory.Batch]: {
icon: <IconSpanBMBatch />,
title: 'Batch',
},
[SpanCategory.Loop]: {
icon: <IconSpanBMBatch />,
title: 'Loop',
},
[SpanCategory.Parallel]: {
icon: <IconSpanBMParallel />,
title: 'Parallel',
},
[SpanCategory.Script]: {
icon: <IconSpanCode />,
title: 'Script',
},
[SpanCategory.CallFlow]: {
icon: <IconSpanWorkflow />,
title: 'CallFlow',
},
[SpanCategory.Connector]: {
icon: <IconSpanBMConnector />,
title: 'Connector',
},
};
interface SpanStatusConfig {
[spanStatus: string]: {
lineStyle?: LineStyle;
};
}
export const spanStatusConfig: SpanStatusConfig = {
[SpanStatus.Success]: {},
[SpanStatus.Error]: {
lineStyle: {
normal: {
stroke: '#FF441E',
},
hover: {
stroke: '#FF441E',
},
select: {
stroke: '#FF441E',
},
},
},
[SpanStatus.Broken]: {},
[SpanStatus.Unknown]: {},
};

View File

@@ -0,0 +1,56 @@
.trace-tree {
.trace-tree-node {
cursor: pointer;
display: flex;
align-items: stretch;
.title {
display: flex;
align-items: center;
margin-left: 4px;
padding: 0 4px;
font-size: 12px;
color: #1D1C23;
white-space: nowrap;
border-radius: 2px;
}
&:global(.selected) {
.title {
background: #2E2E381F;
}
}
&:global(.hover) {
.title {
background: #2E2E3814;
}
}
&:global(.error) {
.title {
color: #FF441E;
}
&:global(.selected) {
.title {
background: #FFE0D2;
}
}
&:global(.hover) {
.title {
background: #FFF3EE;
}
}
}
/* stylelint-disable-next-line no-descending-specificity */
&:global(.disabled) {
cursor: default;
}
}
}

View File

@@ -0,0 +1,131 @@
/*
* 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 FC, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { SceneType, usePageJumpService } from '@coze-arch/bot-hooks';
import Tree, { type TreeNode } from '../tree';
import { getSpanDataByTraceId } from '../../utils/cspan-graph';
import { DataSourceTypeEnum } from '../../typings/graph';
import { spanData2treeData } from './util';
import { type WorkflowJumpParams, type TraceTreeProps } from './typing';
import { defaultProps } from './config';
import styles from './index.module.less';
const TraceTree: FC<TraceTreeProps> = props => {
const [treeData, setTreeData] = useState<TreeNode>();
const [hoverNodeKey, setHoverNodeKey] = useState('');
const { jump } = usePageJumpService();
const {
dataSource: { type: dataType, spanData, traceId },
spaceId,
selectedSpanId,
spanTypeConfigMap,
indentDisabled,
lineStyle: _lineStyle,
globalStyle: _globalStyle,
className,
...restProps
} = props;
const lineStyle = useMemo(
() => ({
normal: Object.assign(
{},
defaultProps.lineStyle?.normal,
_lineStyle?.normal,
),
select: Object.assign(
{},
defaultProps.lineStyle?.select,
_lineStyle?.select,
),
hover: Object.assign(
{},
defaultProps.lineStyle?.hover,
_lineStyle?.hover,
),
}),
[_lineStyle],
);
const globalStyle = useMemo(
() => Object.assign({}, defaultProps.globalStyle, _globalStyle),
[_globalStyle],
);
const handleJumpToWorkflow = ({
workflowID,
executeID,
workflowNodeID,
workflowVersion,
subExecuteID,
}: WorkflowJumpParams) => {
if (!spaceId) {
return;
}
jump(SceneType.BOT__VIEW__WORKFLOW, {
workflowID,
spaceID: spaceId,
botID: '',
executeID,
workflowNodeID,
workflowVersion,
subExecuteID,
newWindow: true,
});
};
// 初始化flamethreadData
useEffect(() => {
if (dataType === DataSourceTypeEnum.SpanData && spanData) {
if (spanData?.length === 0 && treeData === undefined) {
return;
}
const treeNode = spanData2treeData(spanData, spanTypeConfigMap, {
spaceId,
onHoverChange: setHoverNodeKey,
onJumpToWorkflow: handleJumpToWorkflow,
});
setTreeData(treeNode);
} else if (dataType === DataSourceTypeEnum.TraceId && traceId) {
const spans = getSpanDataByTraceId(traceId);
const treeNode = spanData2treeData(spans, spanTypeConfigMap, {
onHoverChange: setHoverNodeKey,
onJumpToWorkflow: handleJumpToWorkflow,
});
setTreeData(treeNode);
}
}, [dataType, spanData, traceId, spanTypeConfigMap]);
return treeData ? (
<Tree
className={classNames(styles['trace-tree'], className)}
treeData={treeData}
disableDefaultHover={true}
hoverKey={hoverNodeKey}
selectedKey={selectedSpanId}
indentDisabled={indentDisabled}
lineStyle={lineStyle}
globalStyle={globalStyle}
{...restProps}
/>
) : null;
};
export default TraceTree;

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 { type TreeProps } from '../tree';
import { type DataSource } from '../../typings/graph';
import { type SpanTypeConfigMap } from '../../typings/config';
export type TraceTreeProps = {
dataSource: DataSource;
spaceId?: string;
selectedSpanId?: string;
spanTypeConfigMap?: SpanTypeConfigMap;
} & Pick<
TreeProps,
| 'indentDisabled'
| 'lineStyle'
| 'globalStyle'
| 'onSelect'
| 'onClick'
| 'onMouseMove'
| 'onMouseEnter'
| 'onMouseLeave'
| 'className'
>;
export interface SpanDetail {
isCozeWorkflowNode: boolean;
workflowLevel: number; // workflow 层级
workflowVersion?: string; // 父节点透传给子节点
}
export interface WorkflowJumpParams {
workflowID: string;
executeID?: string;
workflowNodeID?: string;
workflowVersion?: string;
subExecuteID?: string;
}

View File

@@ -0,0 +1,246 @@
/*
* 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 ReactNode } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozFocus } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
import { Tooltip } from '@coze-arch/bot-semi';
import { IconpanNodeDamaged } from '@coze-arch/bot-icons';
import {
SpanCategory,
SpanStatus,
SpanType,
} from '@coze-arch/bot-api/ob_query_api';
import { type TreeNode, type TreeNodeExtra } from '../tree';
import {
buildTraceTree,
getSpanTitle,
type SpanNode,
} from '../../utils/cspan-graph';
import { getTokens, getSpanProp } from '../../utils/cspan';
import { type CSpan } from '../../typings/cspan';
import { type SpanTypeConfigMap } from '../../typings/config';
import { rootBreakSpanId } from '../../constant';
import { type WorkflowJumpParams, type SpanDetail } from './typing';
import { spanStatusConfig, spanCategoryConfig } from './config';
import styles from './index.module.less';
const genTitleRender = ({
spanTypeConfigMap,
spanInfoMap,
spaceId,
onHoverChange,
onJumpToWorkflow,
}: {
spanTypeConfigMap?: SpanTypeConfigMap;
spanInfoMap?: Record<string, SpanDetail | undefined>;
spaceId?: string;
onHoverChange?: (key: string) => void;
onJumpToWorkflow?: (params: WorkflowJumpParams) => void;
}) => {
const titleRender = (nodeData: TreeNodeExtra): ReactNode => {
const { selected, unindented, hover, key } = nodeData;
const { span } = nodeData?.extra as { span: CSpan };
const { status, latency, category = SpanCategory.Unknown } = span;
const title = getSpanTitle(span, spanTypeConfigMap);
const isCozeWorkflow =
spanInfoMap?.[span.id]?.isCozeWorkflowNode &&
Boolean(getSpanProp(span, 'workflow_id')) &&
spaceId;
const workflowID = getSpanProp(span, 'workflow_id') as string;
const workflowVersion =
getSpanProp(span, 'workflow_version')?.toString() ||
spanInfoMap?.[span.id]?.workflowVersion;
const handleJumpToWorkflow = () => {
const executeID = getSpanProp(span, 'execute_id') as string | undefined;
const workflowNodeID = getSpanProp(span, 'workflow_node_id')?.toString();
const subExecuteID = getSpanProp(span, 'sub_execute_id')?.toString();
onJumpToWorkflow?.({
workflowID,
executeID,
workflowVersion,
workflowNodeID,
subExecuteID,
});
};
const { input_tokens: inputTokens, output_tokens: outputTokens } =
getTokens(span);
let content = '';
if (inputTokens !== undefined && outputTokens !== undefined) {
const tokensStr = inputTokens + outputTokens;
content = `${I18n.t('analytic_query_latency')}: ${latency}ms | ${I18n.t(
'analytic_query_tokens',
)}: ${tokensStr}`;
} else {
content = `${I18n.t('analytic_query_latency')}: ${latency}ms`;
}
const config = spanCategoryConfig[category];
// 虚拟的break的根节点
if (span.id === rootBreakSpanId) {
return (
<div
className={classNames(styles['trace-tree-node'], {
selected: false,
unindented,
hover,
error: status === SpanStatus.Error,
disabled: true,
})}
>
<IconpanNodeDamaged />
</div>
);
} else {
return (
<>
<Tooltip position="right" content={content} trigger="hover">
<div
className={classNames(styles['trace-tree-node'], {
selected,
unindented,
hover,
error: status === SpanStatus.Error,
})}
onMouseEnter={() => onHoverChange?.(key)}
onMouseLeave={() => onHoverChange?.('')}
>
{config?.icon}
<span className={styles.title}>{title}</span>
</div>
</Tooltip>
{isCozeWorkflow &&
workflowID !== undefined &&
workflowVersion !== undefined ? (
<Tooltip position="top" content={I18n.t('view_workflow_details')}>
<Button
icon={<IconCozFocus />}
style={{ width: 16, height: 16, marginLeft: 4 }}
color="secondary"
size="mini"
onClick={e => {
e.stopPropagation();
handleJumpToWorkflow();
}}
/>
</Tooltip>
) : null}
</>
);
}
};
return titleRender;
};
export const spanData2treeData = (
spanData: CSpan[],
spanTypeConfigMap?: SpanTypeConfigMap,
options?: {
spaceId?: string;
onHoverChange?: (key: string) => void;
onJumpToWorkflow?: (params: WorkflowJumpParams) => void;
},
): TreeNode | undefined => {
const traceTree = buildTraceTree(spanData);
const spanInfoMap = getSpanInfoMap(traceTree);
const walk = (span: SpanNode): TreeNode => {
const lineStyle = spanStatusConfig[span.status]?.lineStyle;
let treeNode: TreeNode = {
key: span.id,
title: genTitleRender({ spanTypeConfigMap, spanInfoMap, ...options }),
selectEnabled: true,
indentDisabled: false,
lineStyle,
zIndex: span.status === SpanStatus.Error ? 1 : 0,
extra: {
span,
},
};
// breakSpan节点
if (span.id === rootBreakSpanId) {
treeNode = {
...treeNode,
selectEnabled: false,
indentDisabled: true,
};
}
treeNode.children = span.children?.map(childSpan => walk(childSpan)) ?? [];
return treeNode;
};
return walk(traceTree);
};
export const getSpanInfoMap = (root: SpanNode) => {
const spanInfoMap: Record<string, SpanDetail | undefined> = {};
const bfs = (node: SpanNode) => {
// coze workflow 设置 isCozeWorkflowNode
if (
node.type === SpanType.Workflow &&
getSpanProp(node, 'workflow_schema_type') === 1
) {
const parentLevel = spanInfoMap[node.parent_id]?.workflowLevel || 0;
spanInfoMap[node.id] = {
isCozeWorkflowNode: true,
workflowLevel: parentLevel + 1,
workflowVersion:
parentLevel <= 1
? getSpanProp(node, 'workflow_version')?.toString() ||
spanInfoMap[node.parent_id]?.workflowVersion
: undefined,
};
} else {
// coze workflow 的子节点设置 isCozeWorkflowNode
const { isCozeWorkflowNode, workflowLevel = 0 } =
spanInfoMap[node.parent_id] || {};
if (isCozeWorkflowNode) {
spanInfoMap[node.id] = {
isCozeWorkflowNode: true,
workflowLevel: workflowLevel + 1,
workflowVersion:
workflowLevel <= 1
? spanInfoMap[node.parent_id]?.workflowVersion
: undefined,
};
}
}
// 递归
for (const childNode of node.children || []) {
bfs(childNode);
}
};
bfs(root);
return spanInfoMap;
};

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type GlobalStyle, type LineStyle } from './typing';
export const defaultGlobalStyle: GlobalStyle = {
indent: 24,
verticalInterval: 16,
nodeBoxHeight: 16,
offsetX: 8,
};
export const defaultLineStyle: LineStyle = {
normal: {
stroke: '#ccc',
strokeDasharray: '[]',
strokeWidth: 2,
lineRadius: 6,
lineGap: 0,
},
select: {
stroke: '#333',
},
hover: {
stroke: '#d25e5a',
},
};

View File

@@ -0,0 +1,39 @@
.tree {
display: flex;
// padding: 10px;
overflow: auto;
box-sizing: border-box;
// background-color: #f1f1f1;
.tree-container {
position: relative;
width: fit-content;
height: fit-content;
.tree-path-list {
position: absolute;
width: 100%;
height: 100%;
}
.tree-node-list {
position: relative;
.tree-node {
display: flex;
align-items: flex-start;
.tree-node-box {
display: flex;
align-items: stretch;
}
// .tree-node-icon {
// display: flex;
// justify-content: center;
// align-items: center;
// }
}
}
}
}

View File

@@ -0,0 +1,322 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-magic-numbers -- 本组件中会有很多位置计算的数字,无须处理*/
import { type FC, useCallback, useMemo, useState } from 'react';
import { isFunction, mergeWith } from 'lodash-es';
import { flattenTreeData } from './util';
import type {
TreeProps,
TreeNode,
TreeNodeExtra,
MouseEventParams,
Line,
LineStyle,
GlobalStyle,
} from './typing';
import { defaultGlobalStyle, defaultLineStyle } from './config';
import styles from './index.module.less';
export type {
TreeProps,
TreeNode,
TreeNodeExtra,
MouseEventParams,
LineStyle,
GlobalStyle,
};
const Tree: FC<TreeProps> = ({
treeData,
selectedKey,
disableDefaultHover,
hoverKey: customHoverKey,
indentDisabled = false,
lineStyle: gLineStyle,
globalStyle,
className,
onMouseMove,
onMouseEnter,
onMouseLeave,
onClick,
onSelect,
}) => {
const [hoverKey, setHoverKey] = useState<string>('');
const controlledHoverKey = disableDefaultHover ? customHoverKey : hoverKey;
const { indent, verticalInterval, nodeBoxHeight, offsetX } = useMemo(
() =>
Object.assign(
{},
defaultGlobalStyle,
globalStyle,
) as Required<GlobalStyle>,
[globalStyle],
);
/**
* 使得指定的selectKey的Line置于顶层。
* 通过调整line顺序来实现z-index效果key为${selectKey}的line在最上层
*/
const adjustLineOrder = useCallback(
(lines: Line[]): Line[] => {
let selectedLine, hoverLine;
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.endNode.key === selectedKey) {
selectedLine = lines.splice(i, 1)[0];
} else if (line.endNode.key === controlledHoverKey) {
hoverLine = lines.splice(i, 1)[0];
} else {
i++;
}
}
// 支持根据zIndex控制高度
lines.sort((lineA, lineB) => {
const zIndexA = lineA.endNode.zIndex ?? -1;
const zIndexB = lineB.endNode.zIndex ?? -1;
if (zIndexA > zIndexB) {
return 1;
} else if (zIndexA < zIndexB) {
return -1;
} else {
return 0;
}
});
if (selectedLine) {
lines.push(selectedLine);
}
if (hoverLine) {
lines.push(hoverLine);
}
return lines;
},
[selectedKey, controlledHoverKey],
);
const genLineStyle = useCallback(
(lineStyle?: LineStyle): LineStyle => ({
normal: Object.assign(
{},
defaultLineStyle?.normal,
gLineStyle?.normal,
lineStyle?.normal,
),
select: Object.assign(
{},
defaultLineStyle?.select,
gLineStyle?.select,
lineStyle?.select,
),
hover: Object.assign(
{},
defaultLineStyle?.hover,
gLineStyle?.hover,
lineStyle?.hover,
),
}),
[gLineStyle],
);
/**
* 根据line信息生成svg path。 colNo, rowNum都从0开始
*/
const genSvgPath = useCallback(
(line: Line): string => {
const {
startNode: { colNo: startColNo, rowNo: startRowNo },
endNode: { colNo: endColNo, rowNo: endRowNo, lineStyle },
} = line;
const { normal: normalLineStyle = {} } = genLineStyle(lineStyle);
const { lineRadius = 0, lineGap = 0 } = normalLineStyle;
const nodeHeight = nodeBoxHeight + verticalInterval;
// 起始点
const startX = startColNo * indent + offsetX;
const startY =
startRowNo * nodeHeight + (nodeBoxHeight + verticalInterval / 2);
if (startColNo === endColNo) {
// 竖线的长度
const lineASize =
(endRowNo - startRowNo - 1) * nodeHeight + verticalInterval;
// 移动到起始点
const moveToStartPoint = `M ${startX} ${startY + lineGap}`;
// 竖线
const lineA = `L ${startX} ${startY + lineASize}`;
return `${moveToStartPoint} ${lineA}`;
} else {
// 竖线的长度
const lineASize =
(endRowNo - startRowNo - 1) * nodeHeight +
verticalInterval / 2 +
nodeHeight / 2 -
lineRadius;
// 横线的长度
const lineBSize =
(endColNo - startColNo) * indent - offsetX - lineRadius;
// 结束点的坐标
const endX = startX + lineBSize + lineRadius;
const endY = startY + lineASize + lineRadius;
// 移动到起始点
const moveToStartPoint = `M ${startX} ${startY + lineGap}`;
// 竖线
const lineA = `L ${startX} ${startY + lineASize}`;
// 二次贝塞尔曲线
const qbc = `Q ${startX} ${endY} ${startX + lineRadius} ${endY}`;
// 横线
const lineB = `L ${endX - lineGap} ${endY}`;
return `${moveToStartPoint} ${lineA} ${qbc} ${lineB}`;
}
},
[genLineStyle, indent, nodeBoxHeight, offsetX, verticalInterval],
);
const genLineAttrs = useCallback(
(nodeKey: string, lineStyle: LineStyle) => {
if (controlledHoverKey !== selectedKey) {
if (nodeKey === controlledHoverKey) {
return mergeWith({}, lineStyle.normal, lineStyle.hover);
}
if (nodeKey === selectedKey) {
return mergeWith({}, lineStyle.normal, lineStyle.select);
}
return lineStyle.normal;
} else {
if (nodeKey === controlledHoverKey) {
return mergeWith(
{},
lineStyle.normal,
lineStyle.select,
lineStyle.hover,
);
} else {
return lineStyle.normal;
}
}
},
[controlledHoverKey, selectedKey],
);
const { nodes, lines: orgLines } = flattenTreeData(treeData, {
indentDisabled,
});
const lines = adjustLineOrder(orgLines);
return (
<div className={`${styles.tree} ${className ?? ''}`}>
<div
className={styles['tree-container']}
style={{ marginTop: -verticalInterval / 2 }}
>
<div className={styles['tree-path-list']}>
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
{lines.map((line, index) => {
const path = genSvgPath(line);
const { lineStyle } = line.endNode;
const lineStyle0 = genLineStyle(lineStyle);
const attrs = genLineAttrs(line.endNode.key, lineStyle0);
return (
<path
d={path}
stroke={attrs?.stroke}
strokeWidth={attrs?.strokeWidth}
strokeDasharray={attrs?.strokeDasharray}
fill="none"
// strokeLinecap="round"
key={line.endNode.key}
/>
);
})}
</svg>
</div>
<div className={styles['tree-node-list']}>
{nodes.map(node => {
const { key, title, selectEnabled = true, colNo } = node;
const nodeExtra: TreeNodeExtra = {
...node,
selected: selectedKey === key,
hover: controlledHoverKey === key,
};
return (
<div
className={styles['tree-node']}
style={{
paddingTop: verticalInterval / 2,
paddingBottom: verticalInterval / 2,
}}
key={node.key}
>
<div
className={styles['tree-node-box']}
style={{
marginLeft: colNo * indent,
height: nodeBoxHeight,
}}
onClick={event => {
if (selectEnabled) {
onSelect?.({ node: nodeExtra });
}
onClick?.({ event, node: nodeExtra });
}}
onMouseMove={event => {
onMouseMove?.({ event, node: nodeExtra });
}}
onMouseEnter={event => {
if (selectEnabled) {
setHoverKey(key);
}
onMouseEnter?.({
event,
node: { ...nodeExtra, hover: true },
});
}}
onMouseLeave={event => {
if (selectEnabled) {
setHoverKey('');
}
onMouseLeave?.({
event,
node: { ...nodeExtra, hover: false },
});
}}
>
{isFunction(title) ? title(nodeExtra) : title}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
export default Tree;

View File

@@ -0,0 +1,96 @@
/*
* 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 ReactNode, type SVGAttributes } from 'react';
export type LineAttrs = Pick<
SVGAttributes<unknown>,
'stroke' | 'strokeDasharray' | 'strokeWidth'
> & {
lineRadius?: number; // line圆角半径 注意:这个数值不要大于 indent/2
lineGap?: number; // line距离box的gap
};
export interface LineStyle {
normal?: LineAttrs;
select?: LineAttrs;
hover?: LineAttrs;
}
// tree-node-box
export interface TreeNode {
key: string;
title: ReactNode | ((nodeData: TreeNodeExtra) => ReactNode);
selectEnabled?: boolean; // 默认值 true
indentDisabled?: boolean; // 关闭缩进。 仅针对如下场景生效:子节点中的最后一个节点
lineStyle?: LineStyle; // 当指定了此属性时会覆盖全局的lineStyle
children?: TreeNode[];
linePath?: PathEnum[];
zIndex?: number;
// 其他字段,会透传
extra?: unknown;
}
export enum PathEnum {
Hidden = 0,
Show = 1,
Active = 2,
}
export type TreeNodeExtra = Omit<TreeNode, 'children'> & {
colNo: number;
rowNo: number;
unindented: boolean; // 相对于父节点,是否未缩进
selected: boolean; // 是否被选中
hover: boolean; // 是否hover
};
// 拉平后的TreeNode信息
export type TreeNodeFlatten = Omit<TreeNodeExtra, 'selected' | 'hover'>;
export interface Line {
startNode: TreeNodeFlatten;
endNode: TreeNodeFlatten;
}
export interface GlobalStyle {
indent?: number; // 父节点和子节点的缩进距离
verticalInterval?: number; // node节点的垂直间距
nodeBoxHeight?: number; // node-box节点的高度
offsetX?: number;
}
export interface MouseEventParams {
event: React.MouseEvent<HTMLDivElement>;
node: TreeNodeExtra;
}
export interface TreeProps {
treeData: TreeNode;
selectedKey?: string;
hoverKey?: string;
disableDefaultHover?: boolean;
indentDisabled?: boolean; // 关闭缩进。 仅针对如下场景生效:最后一个节点
lineStyle?: LineStyle;
globalStyle?: GlobalStyle;
className?: string;
onSelect?: (info: Pick<MouseEventParams, 'node'>) => void;
onClick?: (info: MouseEventParams) => void;
onMouseMove?: (info: MouseEventParams) => void;
onMouseEnter?: (info: MouseEventParams) => void;
onMouseLeave?: (info: MouseEventParams) => void;
}

View File

@@ -0,0 +1,75 @@
/*
* 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 { omit } from 'lodash-es';
import { type TreeNodeFlatten, type TreeNode, type Line } from './typing';
/**
* 基于TreeData生成
*
* @param treeData tree原始数据
* @param options.indentDisabled 是否取消缩进。仅针对下述场景有效:异常节点+最后一个节点
*
* @returns
* 1. nodes, 拉平后的node节点信息
* 2. lines, 用于将node进行连接
*/
export const flattenTreeData = (
treeData: TreeNode,
options: {
indentDisabled: boolean;
},
): { nodes: TreeNodeFlatten[]; lines: Line[] } => {
const nodes: TreeNodeFlatten[] = [];
const lines: Line[] = [];
const walk = (
node: TreeNode,
nodeColNo: number,
fatherNodeFlatten?: TreeNodeFlatten,
) => {
const nodeFlatten: TreeNodeFlatten = {
...omit(node, ['children']),
colNo: nodeColNo,
rowNo: nodes.length,
unindented: fatherNodeFlatten?.colNo === nodeColNo, // 未缩进
};
nodes.push(nodeFlatten);
if (fatherNodeFlatten !== undefined) {
lines.push({
startNode: fatherNodeFlatten,
endNode: nodeFlatten,
});
}
if (node.children) {
const childNodes = node.children;
childNodes.forEach((childNode, index) => {
// 取消缩进。 生效场景:异常节点+最后一个节点
const indentDisabled =
childNode.indentDisabled ?? options.indentDisabled;
if (indentDisabled && childNodes.length - 1 === index) {
walk(childNode, nodeColNo, nodeFlatten);
} else {
walk(childNode, nodeColNo + 1, nodeFlatten);
}
});
}
};
walk(treeData, 0);
return { nodes, lines };
};

View File

@@ -0,0 +1,243 @@
/*
* 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 {
SpanCategory,
SpanStatus,
SpanType,
} from '@coze-arch/bot-api/ob_query_api';
import {
SpanCategoryConfigMap,
SpanStatusConfigMap,
SpanTypeConfigMap,
} from '../typings/config';
import { I18n } from '@coze-arch/i18n';
import { StreamingOutputStatus } from '../typings/cspan';
export const spanTypeConfigMap: SpanTypeConfigMap = {
[SpanType.Unknown]: {
label: I18n.t('analytic_query_subtype_value_unknown'),
},
[SpanType.UserInput]: {
label: I18n.t('analytic_query_subtype_value_userinput'),
},
[SpanType.UserInputV2]: {
label: I18n.t('analytic_query_subtype_value_userinput'),
},
[SpanType.ThirdParty]: {
label: I18n.t('analytic_query_subtype_value_thirdparty'),
},
[SpanType.ScheduledTasks]: {
label: I18n.t('analytic_query_subtype_value_scheduledtasks'),
},
[SpanType.OpenDialog]: {
label: I18n.t('analytic_query_subtype_value_opendialog'),
},
[SpanType.InvokeAgent]: {
label: I18n.t('analytic_query_subtype_value_invokeagent'),
},
[SpanType.RestartAgent]: {
label: I18n.t('analytic_query_subtype_value_restartagent'),
},
[SpanType.SwitchAgent]: {
label: I18n.t('analytic_query_subtype_value_switchagent'),
},
[SpanType.LLMCall]: {
label: I18n.t('analytic_query_subtype_value_llmcall'),
},
[SpanType.LLMBatchCall]: {
label: I18n.t('analytic_query_subtype_value_llmbatchcall'),
},
[SpanType.Workflow]: {
label: I18n.t('analytic_query_subtype_value_workflow'),
},
[SpanType.WorkflowStart]: {
label: I18n.t('analytic_query_subtype_value_workflowstart'),
},
[SpanType.WorkflowEnd]: {
label: I18n.t('analytic_query_subtype_value_workflowend'),
},
[SpanType.PluginTool]: {
label: I18n.t('analytic_query_subtype_value_plugintool'),
},
[SpanType.PluginToolBatch]: {
label: I18n.t('analytic_query_subtype_value_plugintoolbatch'),
},
[SpanType.Knowledge]: {
label: I18n.t('analytic_query_subtype_value_knowledge'),
},
[SpanType.Code]: {
label: I18n.t('analytic_query_subtype_value_code'),
},
[SpanType.CodeBatch]: {
label: I18n.t('analytic_query_subtype_value_codebatch'),
},
[SpanType.Condition]: {
label: I18n.t('analytic_query_subtype_value_condition'),
},
[SpanType.Card]: {
label: I18n.t('analytic_query_subtype_value_card'),
},
[SpanType.WorkflowMessage]: {
label: I18n.t('analytic_query_subtype_value_workflow_message'),
},
[SpanType.WorkflowLLMCall]: {
label: I18n.t('analytic_query_subtype_value_llmcall'),
},
[SpanType.WorkflowLLMBatchCall]: {
label: I18n.t('analytic_query_subtype_value_llmbatchcall'),
},
[SpanType.WorkflowCode]: {
label: I18n.t('analytic_query_subtype_value_code'),
},
[SpanType.WorkflowCodeBatch]: {
label: I18n.t('analytic_query_subtype_value_codebatch'),
},
[SpanType.WorkflowCondition]: {
label: I18n.t('analytic_query_subtype_value_condition'),
},
[SpanType.WorkflowPluginTool]: {
label: I18n.t('analytic_query_subtype_value_plugintool'),
},
[SpanType.WorkflowPluginToolBatch]: {
label: I18n.t('analytic_query_subtype_value_plugintoolbatch'),
},
[SpanType.WorkflowKnowledge]: {
label: I18n.t('analytic_query_subtype_value_knowledge'),
},
[SpanType.Chain]: {},
// 特定业务
[SpanType.Hook]: {
label: I18n.t('analytics_query_invoke', {
name: 'Hook',
}),
},
[SpanType.BWStart]: { label: 'BWStart' },
[SpanType.BWEnd]: { label: 'BWEnd' },
[SpanType.BWBatch]: { label: 'BWBatch' },
[SpanType.BWLoop]: { label: 'BWLoop' },
[SpanType.BWCondition]: { label: 'BWCondition' },
[SpanType.BWLLM]: { label: 'BWLLM' },
[SpanType.BWParallel]: { label: 'BWParallel' },
[SpanType.BWScript]: { label: 'BWScript' },
[SpanType.BWVariable]: { label: 'BWVariable' },
[SpanType.BWCallFlow]: { label: 'BWCallFlow' },
[SpanType.BWConnector]: { label: 'BWConnector' },
};
export const spanCategoryConfigMap: SpanCategoryConfigMap = {
[SpanCategory.Unknown]: {
label: I18n.t('analytic_query_type_value_unknown'),
},
[SpanCategory.Start]: {
label: I18n.t('analytic_query_type_value_start'),
},
[SpanCategory.Agent]: {
label: I18n.t('analytic_query_type_value_agent'),
},
[SpanCategory.LLMCall]: {
label: I18n.t('analytic_query_type_value_llmcall'),
},
[SpanCategory.Workflow]: {
label: I18n.t('analytic_query_type_value_workflow'),
},
[SpanCategory.WorkflowStart]: {
label: I18n.t('analytic_query_type_value_workflowstart'),
},
[SpanCategory.WorkflowEnd]: {
label: I18n.t('analytic_query_type_value_workflowend'),
},
[SpanCategory.Plugin]: {
label: I18n.t('analytic_query_type_value_plugin'),
},
[SpanCategory.Knowledge]: {
label: I18n.t('analytic_query_type_value_knowledge'),
},
[SpanCategory.Code]: {
label: I18n.t('analytic_query_type_value_code'),
},
[SpanCategory.Condition]: {
label: I18n.t('analytic_query_type_value_condition'),
},
[SpanCategory.Card]: {
label: I18n.t('analytic_query_type_value_card'),
},
[SpanCategory.Message]: {
label: I18n.t('analytic_query_type_value_message'),
},
[SpanCategory.Variable]: {
label: I18n.t('analytics_query_type_variable'),
},
[SpanCategory.Hook]: {
label: 'Hook',
},
[SpanCategory.Batch]: {
label: 'Batch',
},
[SpanCategory.Loop]: {
label: 'Loop',
},
[SpanCategory.Parallel]: {
label: 'Parallel',
},
[SpanCategory.Script]: {
label: 'Script',
},
[SpanCategory.CallFlow]: {
label: 'CallFlow',
},
[SpanCategory.Connector]: {
label: 'Connector',
},
};
export const spanStatusConfigMap: SpanStatusConfigMap = {
[SpanStatus.Unknown]: {
label: I18n.t('analytic_query_status_unknown'),
},
[SpanStatus.Success]: {
label: I18n.t('analytic_query_status_success'),
},
[SpanStatus.Error]: {
label: I18n.t('analytic_query_status_error'),
},
[SpanStatus.Broken]: {
label: I18n.t('analytic_query_status_broken'),
},
};
export const streamingOutputStatusConfigMap: Record<
StreamingOutputStatus,
{ label?: string } | undefined
> = {
[StreamingOutputStatus.OPEN]: {
label: I18n.t('analytic_streaming_output_status_open'),
},
[StreamingOutputStatus.CLOSE]: {
label: I18n.t('analytic_streaming_output_status_close'),
},
[StreamingOutputStatus.UNDEFINED]: {},
};
export const botEnvConfigMap: Record<string, { label?: string } | undefined> = {
'0': {
label: I18n.t('analytic_query_env_value_botmakerdebug'),
},
'1': {
label: I18n.t('analytic_query_env_value_realuser'),
},
};

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 const virtualStartSpanId = '-10001';
export const rootBreakSpanId = '-10002';

View File

@@ -0,0 +1,252 @@
/*
* 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 {
SpanType,
type Span,
type TraceAdvanceInfo,
SpanStatus,
SpanCategory,
} from '@coze-arch/bot-api/ob_query_api';
import { spans2CSpans } from '../utils/cspan-transform';
import {
type SpanNode,
buildCallTrees,
getRootSpan,
} from '../utils/cspan-graph';
import { genVirtualStart, getSpanProp } from '../utils/cspan';
import {
type SpanCategoryMeta,
type CSpan,
type CSpanAttrInvokeAgent,
type CSPanBatch,
type CTrace,
type CSpanAttrUserInput,
} from '../typings/cspan';
// 对根节点追加traceAdvanceInfo信息
const appendTraceAdvanceInfo = (
spans: CSpan[],
traceAdvanceInfo?: Omit<TraceAdvanceInfo, 'trace_id'>,
): CSpan[] =>
spans.map(span => {
// 修改根节点的状态。 根节点的tokens和status以服务端获取的为准
if (
span.type === SpanType.UserInput ||
span.type === SpanType.UserInputV2
) {
const span0 = span as CSpanAttrUserInput;
return {
...span0,
status: traceAdvanceInfo?.status ?? SpanStatus.Unknown,
input_tokens_sum: traceAdvanceInfo?.tokens.input,
output_tokens_sum: traceAdvanceInfo?.tokens.output,
};
} else {
return span;
}
});
interface TokensSum {
input_tokens_sum: number;
output_tokens_sum: number;
}
const appendSpans = (spans: CSpan[], callTrees: SpanNode[]) => {
const tokensMap: {
[spanId: string]: TokensSum | undefined;
} = {};
const calculateTokensSum = (span: SpanNode): TokensSum => {
const { input_tokens: inputTokens, output_tokens: outputTokens } =
getCSpanTokens(span);
let inputTokensSumRst = inputTokens;
let outputTokensSumRst = outputTokens;
span.children?.forEach((subSpan: SpanNode) => {
const subTokensSum = calculateTokensSum(subSpan);
inputTokensSumRst += subTokensSum.input_tokens_sum;
outputTokensSumRst += subTokensSum.output_tokens_sum;
return span;
});
const tokensSum = {
input_tokens_sum: inputTokensSumRst,
output_tokens_sum: outputTokensSumRst,
};
tokensMap[span.id] = tokensSum;
return tokensSum;
};
callTrees.forEach(callTree => {
calculateTokensSum(callTree);
});
return spans.map(span => {
if (
span.type === SpanType.UserInput ||
span.type === SpanType.UserInputV2
) {
// 根节点input_tokens_sum和output_tokens_sum的数值以服务端获取为准不做计算
return span;
} else {
return {
...span,
input_tokens_sum: tokensMap[span.id]?.input_tokens_sum,
output_tokens_sum: tokensMap[span.id]?.output_tokens_sum,
};
}
});
};
// 获取CSpan节点的tokens信息
const getCSpanTokens = (
span: CSpan,
): {
input_tokens: number;
output_tokens: number;
} => {
if ('spans' in span) {
const spanBatch = span as CSPanBatch;
let inputTokensRst = 0;
let outputTokensRst = 0;
spanBatch.spans.forEach(subSpan => {
const inputTokens = subSpan?.extra?.input_tokens;
const outputTokens = subSpan?.extra?.output_tokens;
if (inputTokens !== undefined) {
inputTokensRst += inputTokens;
}
if (outputTokens !== undefined) {
outputTokensRst += outputTokens;
}
});
return {
input_tokens: inputTokensRst,
output_tokens: outputTokensRst,
};
} else {
// SingleSpan节点
return {
input_tokens: (getSpanProp(span, 'input_tokens') as number) ?? 0,
output_tokens: (getSpanProp(span, 'output_tokens') as number) ?? 0,
};
}
};
// 追加invokeAgentInfo的dialog_round和model字段
const appendRootSpan = (info: { rootSpan: CSpan; spans: CSpan[] }): CTrace => {
const { rootSpan, spans } = info;
const rstSpan: CTrace = rootSpan;
let { extra } = rstSpan;
const invokeAgentSpans = spans.filter(
span => span.type === SpanType.InvokeAgent,
);
if (invokeAgentSpans.length > 0) {
const invokeAgentSpan = invokeAgentSpans[0] as CSpanAttrInvokeAgent;
extra = {
...extra,
dialog_round: invokeAgentSpan.extra?.dialog_round,
model: invokeAgentSpan.extra?.model,
};
}
return {
...rstSpan,
extra,
};
};
interface UseSpanTransformProps {
orgSpans: Span[];
traceAdvanceInfo?: Omit<TraceAdvanceInfo, 'trace_id'>;
spanCategoryMeta?: SpanCategoryMeta;
messageId?: string;
}
interface UseSpanTransformReturn {
rootSpan: CTrace;
spans: CSpan[];
}
// start节点不存在时生成虚拟start节点
export const appendVirtualStart = (spans: CSpan[]): CSpan[] => {
const startSpans = spans.filter(rootSpan => {
const { category } = rootSpan;
return category === SpanCategory.Start;
});
// 生成虚拟span
if (startSpans.length > 0) {
return spans;
} else {
const virtualStartSpan = genVirtualStart(spans);
return spans.concat(virtualStartSpan);
}
};
export const useSpanTransform = (
props: UseSpanTransformProps,
): UseSpanTransformReturn => {
const { orgSpans, traceAdvanceInfo, spanCategoryMeta, messageId } = props;
const rst = useMemo(() => {
let spans = spans2CSpans(orgSpans, spanCategoryMeta);
// 追加虚拟span
spans = appendVirtualStart(spans);
// 追加traceAdvanceInfo信息
spans = appendTraceAdvanceInfo(spans, traceAdvanceInfo);
// 根据spans组装call trees
let callTrees = buildCallTrees(spans);
// 根节点超过 1 个,需要按 message id 过滤
if (callTrees.length > 1 && messageId) {
callTrees = callTrees.filter(
root =>
!('extra' in root) ||
(root.extra && !('message_id' in root.extra)) ||
// 存在 message_id 的情况下,过滤 id 匹配的节点
root.extra?.message_id === messageId,
);
}
const rootSpan = getRootSpan(callTrees, false);
// rootSpan的根节点调整: 追加invokeAgent信息
const rootSpanRst = appendRootSpan({
rootSpan,
spans,
});
const visit = (targetId: string, root: SpanNode): boolean => {
if (root.id === targetId) {
return true;
}
return root.children?.some(subRoot => visit(targetId, subRoot)) ?? false;
};
// 过滤掉不在rootSpan中的节点
spans = spans.filter(span => visit(span.id, rootSpan));
// 对spans节点进行调整: spans中workflow节点tokens累加计算
const spansRst = appendSpans(spans, callTrees);
return {
rootSpan: rootSpanRst,
spans: spansRst,
};
}, [orgSpans, traceAdvanceInfo, spanCategoryMeta, messageId]);
return rst;
};

View File

@@ -0,0 +1,72 @@
/*
* 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 { default as TraceFlamethread } from './components/trace-flamethread';
export { default as TraceTree } from './components/trace-tree';
export { default as TopologyFlow } from './components/topology-flow';
export {
default as Flamethread,
type InteractionEventHandler,
} from './components/flamethread';
export { default as Tree, type MouseEventParams } from './components/tree';
export { useSpanTransform } from './hooks/use-span-transform';
// Tree和Flamethread的参数类型
export { DataSourceTypeEnum } from './typings/graph';
export {
// useSpanTransform相关类型
type SpanCategoryMeta,
// useSpanTransform 生成的定制span
type CSpan,
type CTrace,
type CSpanSingle,
type CSPanBatch,
type CSpanAttrUserInput,
type CSpanAttrInvokeAgent,
type CSpanAttrRestartAgent,
type CSpanAttrSwitchAgent,
type CSpanAttrLLMCall,
type CSpanAttrLLMBatchCall,
type CSpanAttrWorkflow,
type CSpanAttrWorkflowEnd,
type CSpanAttrCode,
type CSpanAttrCodeBatch,
type CSpanAttrCondition,
type CSpanAttrPluginTool,
type CSpanAttrPluginToolBatch,
type CSpanAttrKnowledge,
type CSpanAttrChain,
StreamingOutputStatus,
} from './typings/cspan';
export {
spanTypeConfigMap,
botEnvConfigMap,
spanCategoryConfigMap,
streamingOutputStatusConfigMap,
} from './config/cspan';
export {
isBatchSpanType,
isVisibleSpan,
checkIsBatchBasicCSpan,
getTokens,
getSpanProp,
} from './utils/cspan';
export { span2CSpan } from './utils/cspan-transform';
export { fieldItemHandlers, type FieldItem } from './utils/field-item-handler';

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface SpanTypeConfig {
label?: string;
}
/** key: SpanType */
export type SpanTypeConfigMap = Record<number, SpanTypeConfig | undefined>;
interface SpanCategoryConfig {
label: string;
}
/** key: SpanCategory */
export type SpanCategoryConfigMap = Record<
number,
SpanCategoryConfig | undefined
>;
interface SpanStatusConfig {
label: string;
}
export interface SpanStatusConfigMap {
[x: number]: SpanStatusConfig | undefined;
}

View File

@@ -0,0 +1,190 @@
/*
* 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 Span,
type AttrUserInput,
type AttrInvokeAgent,
type AttrCode,
type AttrCodeBatch,
type AttrCondition,
type AttrKnowledge,
type AttrLLMBatchCall,
type AttrLLMCall,
type AttrPluginTool,
type AttrPluginToolBatch,
type AttrRestartAgent,
type AttrSwitchAgent,
type AttrWorkflow,
type AttrWorkflowEnd,
type SpanCategory,
type SpanType,
type SpanStatus,
type AttrChain,
type AttrWorkflowMessage,
type AttrCard,
type AttrWorkflowLLMCall,
type AttrWorkflowCode,
type AttrWorkflowCodeBatch,
type AttrWorkflowCondition,
type AttrWorkflowKnowledge,
type AttrWorkflowLLMBatchCall,
type AttrWorkflowPluginTool,
type AttrWorkflowPluginToolBatch,
type AttrBWStart,
type AttrBWEnd,
type AttrBWBatch,
type AttrBWLoop,
type AttrBWCondition,
type AttrBWLLM,
type AttrBWParallel,
type AttrBWScript,
type AttrBWVariable,
type AttrBWCallFlow,
type AttrBWConnector,
type AttrHook,
} from '@coze-arch/bot-api/ob_query_api';
type CSpanCommonProp = Pick<
Span,
'trace_id' | 'id' | 'parent_id' | 'name' | 'type' | 'status'
> & {
start_time: number; // 默认为Int64用起来不方便
latency: number; // 默认为Int64用起来不方便
category?: SpanCategory; // 加载Meta失败时才为空
input_tokens_sum?: number; // 扩展字段用于存储子节点的input_tokens之和
output_tokens_sum?: number; // 扩展字段用于存储子节点的output_tokens之和
};
type GenCSpan<T> = CSpanCommonProp & {
extra?: T;
};
export type CSpanAttrUserInput = GenCSpan<AttrUserInput>;
export type CSpanAttrInvokeAgent = GenCSpan<AttrInvokeAgent>;
export type CSpanAttrRestartAgent = GenCSpan<AttrRestartAgent>;
export type CSpanAttrSwitchAgent = GenCSpan<AttrSwitchAgent>;
export type CSpanAttrLLMCall = GenCSpan<AttrLLMCall>;
export type CSpanAttrWorkflowLLMCall = GenCSpan<AttrWorkflowLLMCall>;
export type CSpanAttrLLMBatchCall = GenCSpan<AttrLLMBatchCall>;
export type CSpanAttrWorkflowLLMBatchCall = GenCSpan<AttrWorkflowLLMBatchCall>;
export type CSpanAttrWorkflow = GenCSpan<AttrWorkflow>;
export type CSpanAttrWorkflowEnd = GenCSpan<AttrWorkflowEnd>;
export type CSpanAttrCode = GenCSpan<AttrCode>;
export type CSpanAttrWorkflowCode = GenCSpan<AttrWorkflowCode>;
export type CSpanAttrCodeBatch = GenCSpan<AttrCodeBatch>;
export type CSpanAttrWorkflowCodeBatch = GenCSpan<AttrWorkflowCodeBatch>;
export type CSpanAttrCondition = GenCSpan<AttrCondition>;
export type CSpanAttrWorkflowCondition = GenCSpan<AttrWorkflowCondition>;
export type CSpanAttrPluginTool = GenCSpan<AttrPluginTool>;
export type CSpanAttrWorkflowPluginTool = GenCSpan<AttrWorkflowPluginTool>;
export type CSpanAttrPluginToolBatch = GenCSpan<AttrPluginToolBatch>;
export type CSpanAttrWorkflowPluginToolBatch =
GenCSpan<AttrWorkflowPluginToolBatch>;
export type CSpanAttrKnowledge = GenCSpan<AttrKnowledge>;
export type CSpanAttrWorkflowKnowledge = GenCSpan<AttrWorkflowKnowledge>;
export type CSpanAttrChain = GenCSpan<AttrChain>;
export type CSpanAttrCard = GenCSpan<AttrCard>;
export type CSpanAttrWorkflowMessage = GenCSpan<AttrWorkflowMessage>;
export type CSpanAttrHook = GenCSpan<AttrHook>;
export type CSpanAttrBWStart = GenCSpan<AttrBWStart>;
export type CSpanAttrBWEnd = GenCSpan<AttrBWEnd>;
export type CSpanAttrBWBatch = GenCSpan<AttrBWBatch>;
export type CSpanAttrBWLoop = GenCSpan<AttrBWLoop>;
export type CSpanAttrBWCondition = GenCSpan<AttrBWCondition>;
export type CSpanAttrBWLLM = GenCSpan<AttrBWLLM>;
export type CSpanAttrBWParallel = GenCSpan<AttrBWParallel>;
export type CSpanAttrBWScript = GenCSpan<AttrBWScript>;
export type CSpanAttrBWVariable = GenCSpan<AttrBWVariable>;
export type CSpanAttrBWCallFlow = GenCSpan<AttrBWCallFlow>;
export type CSpanAttrBWConnector = GenCSpan<AttrBWConnector>;
export type CSpanSingle =
| CSpanAttrUserInput
| CSpanAttrInvokeAgent
| CSpanAttrRestartAgent
| CSpanAttrSwitchAgent
| CSpanAttrLLMCall
| CSpanAttrLLMBatchCall
| CSpanAttrWorkflow
| CSpanAttrWorkflowEnd
| CSpanAttrCode
| CSpanAttrCodeBatch
| CSpanAttrCondition
| CSpanAttrPluginTool
| CSpanAttrPluginToolBatch
| CSpanAttrKnowledge
| CSpanAttrChain
| CSpanAttrCard
| CSpanAttrWorkflowMessage
| CSpanAttrWorkflowLLMCall
| CSpanAttrWorkflowLLMBatchCall
| CSpanAttrWorkflowCode
| CSpanAttrWorkflowCodeBatch
| CSpanAttrWorkflowCondition
| CSpanAttrWorkflowPluginTool
| CSpanAttrWorkflowPluginToolBatch
| CSpanAttrWorkflowKnowledge
| CSpanAttrHook
| CSpanAttrBWStart
| CSpanAttrBWEnd
| CSpanAttrBWBatch
| CSpanAttrBWLoop
| CSpanAttrBWCondition
| CSpanAttrBWLLM
| CSpanAttrBWParallel
| CSpanAttrBWScript
| CSpanAttrBWVariable
| CSpanAttrBWCallFlow
| CSpanAttrBWConnector;
export type CSpanSingleForBatch =
| CSpanAttrLLMBatchCall
| CSpanAttrWorkflowLLMBatchCall
| CSpanAttrCodeBatch
| CSpanAttrWorkflowCodeBatch
| CSpanAttrPluginToolBatch
| CSpanAttrWorkflowPluginToolBatch;
export type CSPanBatch = CSpanCommonProp & {
spans: CSpanSingleForBatch[];
workflow_node_id?: string;
};
export type CSpan = CSpanSingle | CSPanBatch;
type AttrUserInputExtra = Partial<CSpanAttrUserInput['extra']> & {
dialog_round?: number;
model?: string;
input_tokens?: number;
output_tokens?: number;
};
export type CTrace = Omit<CSpanAttrUserInput, 'extra' | 'status'> & {
status?: SpanStatus;
extra?: AttrUserInputExtra;
};
export type SpanCategoryMeta = Record<SpanCategory, SpanType[] | undefined>;
/** key: SpanCategory */
export type SpanCategoryMap = Record<number, SpanCategory>;
export enum StreamingOutputStatus {
OPEN = 'open',
CLOSE = 'close',
UNDEFINED = 'undefined',
}

View File

@@ -0,0 +1,29 @@
/*
* 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 CSpan } from './cspan';
export enum DataSourceTypeEnum {
SpanData = 'SpanData',
TraceId = 'TraceId',
}
export interface DataSource {
// 取值为traceId时组件会根据traceId查询SpanData
type: DataSourceTypeEnum;
spanData?: CSpan[]; // type为spanData时特有字段
traceId?: string; // type为traceId时特有字段
}

View File

@@ -0,0 +1,292 @@
/*
* 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 { omit } from 'lodash-es';
import {
SpanCategory,
SpanStatus,
SpanType,
} from '@coze-arch/bot-api/ob_query_api';
import { type CSpan } from '../typings/cspan';
import { rootBreakSpanId } from '../constant';
import {
spanStatusConfigMap as defaultSpanStatusConfigMap,
spanTypeConfigMap as defaultSpanTypeConfigMap,
} from '../config/cspan';
import { genVirtualStart, isVisibleSpan } from './cspan';
export type SpanNode = CSpan & {
parent?: SpanNode;
children?: SpanNode[];
};
export const getSpanDataByTraceId = (traceId: string): CSpan[] => [];
// 获取tree的跟节点
export const buildCallTrees = (
spans: CSpan[],
splitBatchSpan = true,
): SpanNode[] => {
const roots: SpanNode[] = [];
const map: {
[spanId: string]: SpanNode;
} = {};
spans.forEach(span => {
const curSpan = { ...span, children: [] };
// Batch节点
if ('spans' in span && splitBatchSpan) {
span.spans.forEach(subSpan => {
map[subSpan.id] = curSpan;
});
} else {
const { id: spanId } = span;
map[spanId] = curSpan;
}
});
spans.forEach(span => {
const { id: spanId, parent_id: parentSpanId } = span;
const spanNode = map[spanId];
const parentSpanNode = map[parentSpanId];
if (parentSpanId === '' || parentSpanNode === undefined) {
roots.push(spanNode);
} else {
parentSpanNode.children = parentSpanNode.children ?? [];
parentSpanNode.children.push(spanNode);
spanNode.parent = parentSpanNode;
}
});
return roots;
};
export const getRootSpan = (spans: SpanNode[], needBuildTrees = true) => {
const rootSpans = needBuildTrees ? buildCallTrees(spans) : spans;
const startSpans: SpanNode[] = [];
rootSpans.forEach(rootSpan => {
const { category } = rootSpan;
if (category === SpanCategory.Start) {
startSpans.push(rootSpan);
}
});
// 无start的场景: 虚拟一个startSpan供多方使用火焰图树状图详情图以确保一致多个startSpans则取第一个
return startSpans.length > 0 ? startSpans[0] : genVirtualStart(rootSpans);
};
export const getBreakSpans = (spans: SpanNode[], needBuildTrees = true) => {
const rootSpans = needBuildTrees ? buildCallTrees(spans) : spans;
const breakSpans: SpanNode[] = [];
rootSpans.forEach(rootSpan => {
const { category } = rootSpan;
if (category !== SpanCategory.Start) {
breakSpans.push(rootSpan);
}
});
return breakSpans;
};
export const compareByStartAt = (spanA: SpanNode, spanB: SpanNode) => {
const startAtA = spanA.start_time;
const startAtB = spanB.start_time;
if (startAtA > startAtB) {
return 1;
} else if (startAtA < startAtB) {
return -1;
} else {
return 0;
}
};
export const compareByEndAt = (spanA: SpanNode, spanB: SpanNode) => {
const endAtA = spanA.start_time + spanA.latency;
const endAtB = spanB.start_time + spanB.latency;
if (endAtA > endAtB) {
return 1;
} else if (endAtA < endAtB) {
return -1;
} else {
return 0;
}
};
export const getSpanTitle = (
span: CSpan,
spanTypeConfigMap = defaultSpanTypeConfigMap,
) => {
const { type, name = '' } = span;
const typeName = spanTypeConfigMap[type]?.label ?? '';
if (name && name !== typeName) {
return `${typeName} ${name}`;
} else {
return typeName;
}
};
export const getStatusLabel = (
span: CSpan,
spanStatusConfigMap = defaultSpanStatusConfigMap,
) => {
const { status } = span;
return spanStatusConfigMap[status]?.label ?? '';
};
// start节点不存在时生成虚拟start节点
const getRootBreakSpan = (breakSpans: SpanNode[]): SpanNode => ({
id: rootBreakSpanId,
parent_id: '',
trace_id: '',
name: '',
type: SpanType.UserInput,
status: SpanStatus.Broken,
start_time: -1,
latency: -1,
children: breakSpans,
});
// 根据switchAgent/restartAgent建立父子关系
const handleAgent = (spans: SpanNode[]): SpanNode[] => {
const getAgent = (startAt: number, agents: SpanNode[]) => {
const len = agents.length;
for (let i = 0; i < len; i++) {
const curAgent = agents[i];
const nextAgent = agents[i + 1];
const curEndAt = curAgent.start_time + curAgent.latency;
const nextEndAt = nextAgent
? nextAgent.start_time + nextAgent.latency
: Number.POSITIVE_INFINITY;
if (startAt >= curEndAt && startAt <= nextEndAt) {
return agents[i];
}
}
return undefined;
};
const agentSpans = spans
.filter(
span =>
span.type === SpanType.SwitchAgent ||
span.type === SpanType.RestartAgent,
)
.sort(compareByEndAt);
const normalSpans = spans
.filter(
span =>
span.type !== SpanType.SwitchAgent &&
span.type !== SpanType.RestartAgent,
)
.sort(compareByStartAt);
const rstSpans: SpanNode[] = [];
normalSpans.forEach(span => {
const agent = getAgent(span.start_time, agentSpans);
if (agent) {
agent.children = agent.children ?? [];
agent.children?.push(span);
span.parent = agent;
} else {
rstSpans.push(span);
}
});
return [...rstSpans, ...agentSpans];
};
// 只有特殊的节点类型可以作为根节点
const isTreeRootSpanType = (type: SpanType) =>
[
SpanType.InvokeAgent,
SpanType.Workflow,
SpanType.LLMBatchCall,
SpanType.LLMCall,
SpanType.WorkflowLLMCall,
SpanType.WorkflowLLMBatchCall,
// BlockWise的都放在这里
SpanType.BWStart,
SpanType.BWEnd,
SpanType.BWBatch,
SpanType.BWLoop,
SpanType.BWCondition,
SpanType.BWLLM,
SpanType.BWParallel,
SpanType.BWScript,
SpanType.BWVariable,
SpanType.BWCallFlow,
SpanType.BWConnector,
// 新增类型都支持层级
SpanType.Hook,
].includes(type);
// 依据调用树构建TraceTree
const callTree2TraceTree = (rootSpan: SpanNode): SpanNode => {
const rstSpans: SpanNode[] = [];
const walk = (span: SpanNode) => {
span.children?.forEach(subSpan => {
const { type } = subSpan;
if (isTreeRootSpanType(type)) {
rstSpans.push(callTree2TraceTree(subSpan));
} else {
if (isVisibleSpan(subSpan)) {
// 当前节点加入到 rootSpan.children
rstSpans.push(omit(subSpan, 'children'));
}
// 递归子节点(当前节点)。 注意:隐藏的节点类型,也要递归的。 当前节点隐藏,其子节点有可能是显示的
walk(subSpan);
}
});
};
walk(rootSpan);
return {
...rootSpan,
children: handleAgent(rstSpans),
};
};
export const buildTraceTree = (spans: SpanNode[], splitBatchSpan?: boolean) => {
// 1. 根据spans组装call trees
const callTrees = buildCallTrees(spans, splitBatchSpan);
// 2. 生成startSpan
const startSpan: SpanNode = getRootSpan(callTrees, false);
// 3. 获取 break节点(非start的根节点都是breakSpan)
const breakSpans: SpanNode[] = getBreakSpans(callTrees, false);
// 4. 根据调用tree生成PRD中的Tree(即PRD中的Tree)
const treeStartSpan = callTree2TraceTree(startSpan);
if (breakSpans.length > 0) {
// 5. 将所有breakSpans挂载到rootBreakSpan节点下
const breakSpan: SpanNode = getRootBreakSpan(breakSpans);
// 6. 根据调用tree生成TraceTree
const treeBreakSpan = callTree2TraceTree(breakSpan);
// 7. 将treeBreakSpan挂在到treeStartSpan下
treeStartSpan.children = treeStartSpan.children ?? [];
treeStartSpan.children.push(treeBreakSpan);
treeBreakSpan.parent = treeStartSpan;
}
return treeStartSpan;
};

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { pick, uniqBy } from 'lodash-es';
import {
type Span,
SpanStatus,
type SpanCategory,
} from '@coze-arch/bot-api/ob_query_api';
import {
type CSpanSingle,
type CSpan,
type CSPanBatch,
type SpanCategoryMeta,
type SpanCategoryMap,
type CSpanSingleForBatch,
} from '../typings/cspan';
import { isBatchSpanType } from './cspan';
const compareByStartAt = (
a: CSpanSingleForBatch,
b: CSpanSingleForBatch,
): number => {
if (a.start_time > b.start_time) {
return 1;
} else if (a.start_time < b.start_time) {
return -1;
} else {
return 0;
}
};
const compareByTaskIndex = (
a: CSpanSingleForBatch,
b: CSpanSingleForBatch,
): number => {
const taskIndexA = a.extra?.task_index ?? 0;
const taskIndexB = a.extra?.task_index ?? 0;
if (taskIndexA > taskIndexB) {
return 1;
} else if (taskIndexA < taskIndexB) {
return -1;
} else {
return 0;
}
};
const getStartAtForBatch = (spans: CSpanSingleForBatch[]): number => {
let startAt = Number.POSITIVE_INFINITY;
spans.forEach(span => {
startAt = Math.min(span.start_time, startAt);
});
return startAt;
};
const getLatencyForBatch = (spans: CSpanSingleForBatch[]): number => {
let endAt = Number.NEGATIVE_INFINITY;
spans.forEach(span => {
endAt = Math.max(span.start_time + span.latency, endAt);
});
const startAt = getStartAtForBatch(spans);
return endAt - startAt;
};
const getStatusForBatch = (spans: CSpanSingleForBatch[]): SpanStatus => {
let isSuccess = true;
spans.forEach(span => {
if (span.status === SpanStatus.Error) {
isSuccess = false;
}
});
return isSuccess ? SpanStatus.Success : SpanStatus.Error;
};
// spans直接聚合生成batchSpan
const genBatchSpan = function (
spans: CSpanSingleForBatch[],
spanCategoryMap?: SpanCategoryMap,
): CSPanBatch | undefined {
if (spans.length === 0) {
return undefined;
}
// 合法性检查
const taskTotal = spans[0].extra?.task_total;
const spans0 = spans.filter(curSpan => {
const curTaskTotal = curSpan.extra?.task_total;
return curTaskTotal !== taskTotal;
});
// taskTotal不全部相等数据不合法
if (spans0.length > 0) {
return undefined;
}
return {
...pick(spans[0], ['trace_id', 'id', 'parent_id', 'name', 'type']),
category: spanCategoryMap?.[spans[0].type],
status: getStatusForBatch(spans),
start_time: getStartAtForBatch(spans),
latency: getLatencyForBatch(spans),
spans: spans.sort(compareByTaskIndex),
workflow_node_id: spans[0].extra?.workflow_node_id,
};
};
const aggregationBatchSpan = function (
spans: CSpanSingleForBatch[],
spanCategoryMap?: SpanCategoryMap,
) {
const batchSpans: CSPanBatch[] = [];
// 根据 workflowNodeId + type对span进行归类
const map: {
[key: string]: CSpanSingleForBatch[];
} = {};
spans.forEach(span => {
const { type } = span;
const workflowNodeId = span.extra?.workflow_node_id;
if (!workflowNodeId) {
return;
}
map[type + workflowNodeId] = map[type + workflowNodeId] ?? [];
map[type + workflowNodeId].push(span);
});
// 进一步根据时间+序号进行归类生成CSpanBatch
Object.keys(map).forEach(key => {
const workflowSpans = map[key];
// 排序:时间
workflowSpans.sort(compareByStartAt);
// 根据task_index进行聚合
let curTaskIndexs: number[] = [];
let curSpans: CSpanSingleForBatch[] = [];
workflowSpans.forEach(span => {
const taskIndex = span.extra?.task_index;
if (taskIndex === undefined) {
return;
}
if (curTaskIndexs.includes(taskIndex)) {
// 序号存在了,则新开启一组
const batchSpan = genBatchSpan(curSpans, spanCategoryMap);
if (batchSpan) {
batchSpans.push(batchSpan);
}
curTaskIndexs = [];
curSpans = [];
}
curTaskIndexs.push(taskIndex);
curSpans.push(span);
});
const batchSpan = genBatchSpan(curSpans, spanCategoryMap);
if (batchSpan) {
batchSpans.push(batchSpan);
}
});
return batchSpans;
};
/**
* 处理原始Span将额外节点字段统一到extra
* @param span Span
* @returns CSpanSingle
*/
// eslint-disable-next-line complexity -- 参数过多
export const span2CSpan = function (
span: Span,
spanCategoryMap?: SpanCategoryMap,
): CSpanSingle {
const cspan: CSpanSingle = {
trace_id: span.trace_id,
id: span.id,
parent_id: span.parent_id,
name: span.name,
type: span.type,
start_time: Number(span.start_time),
latency: Number(span.latency),
status: span.status,
category: spanCategoryMap?.[span.type],
};
cspan.extra =
span.attr_user_input ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(span as any).attr_user_input_v2 ??
span.attr_invoke_agent ??
span.attr_restart_agent ??
span.attr_switch_agent ??
span.attr_llm_call ??
span.attr_workflow_llm_call ??
span.attr_llm_batch_call ??
span.attr_workflow_llm_batch_call ??
span.attr_workflow ??
span.attr_workflow_end ??
span.attr_code ??
span.attr_workflow_code ??
span.attr_code_batch ??
span.attr_workflow_code_batch ??
span.attr_condition ??
span.attr_workflow_condition ??
span.attr_plugin_tool ??
span.attr_workflow_plugin_tool ??
span.attr_plugin_tool_batch ??
span.attr_workflow_plugin_tool_batch ??
span.attr_knowledge ??
span.attr_workflow_knowledge ??
span.attr_card ??
span.attr_workflow_message ??
span.attr_chain ??
span.attr_hook ??
span.attr_bw_start ??
span.attr_bw_end ??
span.attr_bw_batch ??
span.attr_bw_loop ??
span.attr_bw_condition ??
span.attr_bw_llm ??
span.attr_bw_parallel ??
span.attr_bw_variable ??
span.attr_bw_call_flow ??
span.attr_bw_connector ??
span.attr_bw_script;
return cspan;
};
const genSpanCategoryMap = (
spanCategoryMeta: SpanCategoryMeta,
): SpanCategoryMap => {
const map: SpanCategoryMap = {};
Object.keys(spanCategoryMeta).forEach(key => {
const spanCategoryValue = Number(key) as SpanCategory;
const spanTypes = spanCategoryMeta[spanCategoryValue];
spanTypes?.forEach(spanTypeValue => {
map[spanTypeValue] = spanCategoryValue;
});
});
return map as SpanCategoryMap;
};
export const spans2CSpans = function (
spans: Span[],
spanCategoryMeta?: SpanCategoryMeta,
): CSpan[] {
const spanCategoryMap = spanCategoryMeta
? genSpanCategoryMap(spanCategoryMeta)
: undefined;
// 根据span.id进行去重
const uniqSpans = uniqBy(spans, 'id');
// Span -> CSpanSingle
const cSpans = uniqSpans.map(span => span2CSpan(span, spanCategoryMap));
const singleCSpans = cSpans.filter(({ type }) => !isBatchSpanType(type));
// CSpanSingle[] -> CSpanBatch[]
const batchCSpans = aggregationBatchSpan(
cSpans.filter(({ type }) => isBatchSpanType(type)) as CSpanSingleForBatch[],
spanCategoryMap,
);
return [...singleCSpans, ...batchCSpans];
};

View File

@@ -0,0 +1,180 @@
/*
* 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 {
SpanCategory,
SpanStatus,
SpanType,
} from '@coze-arch/bot-api/ob_query_api';
import {
type CTrace,
type CSPanBatch,
type CSpan,
type CSpanSingle,
} from '../typings/cspan';
import { virtualStartSpanId } from '../constant';
export const getSpanProp = (span: CSpan | CTrace, key: string) => {
if (checkIsBatchBasicCSpan(span)) {
const batchSpan = span as CSPanBatch;
return (
batchSpan[key as keyof CSPanBatch] ??
batchSpan.spans[0]?.extra?.[key as keyof CSPanBatch['spans'][0]['extra']]
);
} else {
const singleSpan = span as CSpanSingle;
return (
singleSpan[key as keyof CSpanSingle] ??
singleSpan.extra?.[key as keyof CSpanSingle['extra']]
);
}
};
export const getTokens = (
span: CSpan,
): {
input_tokens?: number;
output_tokens?: number;
} => {
if ('spans' in span) {
const spanBatch = span as CSPanBatch;
let inputTokensSum = 0;
let outputTokensSum = 0;
spanBatch.spans.forEach(subSpan => {
const inputTokens = subSpan?.extra?.input_tokens ?? 0;
const outputTokens = subSpan?.extra?.output_tokens ?? 0;
inputTokensSum += inputTokens;
outputTokensSum += outputTokens;
});
return {
input_tokens: inputTokensSum,
output_tokens: outputTokensSum,
};
} else if (
span.type === SpanType.UserInput ||
span.type === SpanType.UserInputV2 ||
span.type === SpanType.Workflow
) {
// SingleSpan节点 - Workflow
const inputTokens = getSpanProp(span, 'input_tokens_sum') as number;
const outputTokens = getSpanProp(span, 'output_tokens_sum') as number;
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
} else {
// SingleSpan节点 - 非workflow节点
const inputTokens = getSpanProp(span, 'input_tokens') as number;
const outputTokens = getSpanProp(span, 'output_tokens') as number;
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
}
};
export const isBatchSpanType = (type: SpanType): boolean => {
const whites = [
SpanType.LLMBatchCall,
SpanType.WorkflowLLMBatchCall,
SpanType.PluginToolBatch,
SpanType.WorkflowPluginToolBatch,
SpanType.CodeBatch,
SpanType.WorkflowCodeBatch,
];
return whites.includes(type);
};
export const checkIsBatchBasicCSpan = (span: CSpan | CTrace) => 'spans' in span;
export const isVisibleSpan = (span: CSpan) => {
const whites = [
SpanType.Unknown,
SpanType.UserInput,
SpanType.UserInputV2,
SpanType.ThirdParty,
SpanType.ScheduledTasks,
SpanType.OpenDialog,
SpanType.InvokeAgent,
SpanType.RestartAgent,
SpanType.SwitchAgent,
SpanType.LLMCall,
SpanType.WorkflowLLMCall,
SpanType.LLMBatchCall,
SpanType.WorkflowLLMBatchCall,
SpanType.Workflow,
SpanType.WorkflowStart,
SpanType.WorkflowEnd,
SpanType.PluginTool,
SpanType.WorkflowPluginTool,
SpanType.PluginToolBatch,
SpanType.WorkflowPluginToolBatch,
SpanType.Knowledge,
SpanType.WorkflowKnowledge,
SpanType.Code,
SpanType.WorkflowCode,
SpanType.CodeBatch,
SpanType.WorkflowCodeBatch,
SpanType.Condition,
SpanType.WorkflowCondition,
SpanType.Card,
SpanType.WorkflowMessage,
SpanType.Hook,
SpanType.BWStart,
SpanType.BWEnd,
SpanType.BWBatch,
SpanType.BWLoop,
SpanType.BWCondition,
SpanType.BWLLM,
SpanType.BWParallel,
SpanType.BWScript,
SpanType.BWVariable,
SpanType.BWCallFlow,
SpanType.BWConnector,
];
return whites.includes(span.type);
};
export const genVirtualStart = (spans: CSpan[]): CSpan => {
let startAt = Number.POSITIVE_INFINITY;
let endAt = Number.NEGATIVE_INFINITY;
spans.forEach(span => {
startAt = Math.min(startAt, span.start_time);
endAt = Math.max(endAt, span.start_time + span.latency);
});
if (
startAt === Number.POSITIVE_INFINITY ||
endAt === Number.NEGATIVE_INFINITY
) {
startAt = 0;
endAt = 0;
}
return {
id: virtualStartSpanId,
parent_id: '',
trace_id: '',
name: '',
type: SpanType.UserInput,
category: SpanCategory.Start,
status: SpanStatus.Unknown,
start_time: startAt,
latency: endAt - startAt,
};
};

View File

@@ -0,0 +1,257 @@
/*
* 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 ReactNode } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
type CSpanAttrInvokeAgent,
type CSpan,
type StreamingOutputStatus,
} from '../typings/cspan';
import {
botEnvConfigMap,
spanCategoryConfigMap,
spanTypeConfigMap,
streamingOutputStatusConfigMap,
} from '../config/cspan';
import { formatTime } from './format-time';
import { getSpanProp } from './cspan';
export interface FieldItem {
key?: string | ReactNode;
value?: string | number | boolean | ReactNode;
}
const getFieldCategory = (span: CSpan): FieldItem => {
const { category } = span;
const categoryConfig =
category !== undefined ? spanCategoryConfigMap[category] : undefined;
return {
key: I18n.t('analytic_query_type'),
value: categoryConfig?.label,
};
};
const getFieldType = (span: CSpan): FieldItem => {
const { type } = span;
const typeConfig = spanTypeConfigMap[type];
return {
key: I18n.t('analytic_query_subtype'),
value: typeConfig?.label,
};
};
const getFieldOS = (span: CSpan): FieldItem => {
const os = getSpanProp(span, 'os');
const osVersion = getSpanProp(span, 'os_version');
return {
key: I18n.t('analytic_query_os'),
value: os ? `${os}${osVersion ? ` ${osVersion}` : ''}` : undefined,
};
};
const getFieldLatency = (span: CSpan): FieldItem => {
const { latency } = span;
return {
key: I18n.t('analytic_query_latency'),
value: latency !== undefined ? `${latency}ms` : undefined,
};
};
const getFieldName = (span: CSpan): FieldItem => {
const { name } = span;
return {
key: I18n.t('analytic_query_name'),
value: name,
};
};
const getFieldOffline = (span: CSpan): FieldItem => {
const botEnv = getSpanProp(span, 'bot_env') as string;
const botEnvConfig =
botEnv !== undefined ? botEnvConfigMap[botEnv] : undefined;
return {
key: I18n.t('analytic_query_env'),
value: botEnvConfig?.label,
};
};
const getFieldStartTime = (span: CSpan): FieldItem => {
const { start_time } = span;
return {
key: I18n.t('analytic_query_starttime'),
value: formatTime(start_time),
};
};
const getFieldEndTime = (span: CSpan): FieldItem => {
const startAt = span?.start_time;
const latency = span?.latency;
return {
key: I18n.t('analytic_query_endtime'),
value:
startAt !== undefined && latency !== undefined
? formatTime(Number(startAt) + Number(latency))
: undefined,
};
};
const getFieldCallType = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_calltype'),
value: getSpanProp(span, 'call_type') as string,
});
const getFieldAgentType = (_span: CSpan): FieldItem => {
const span = _span as CSpanAttrInvokeAgent;
return {
key: I18n.t('analytic_query_agenttype'),
value: span.extra?.agent_type,
};
};
const getFieldModel = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_model'),
value: getSpanProp(span, 'model') as string,
});
const getFieldTemperature = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_temperature'),
value: getSpanProp(span, 'temperature') as string,
});
const getFieldDialogRound = (_span: CSpan): FieldItem => {
const span = _span as CSpanAttrInvokeAgent;
return {
key: I18n.t('analytic_query_diagloground'),
value: span.extra?.dialog_round,
};
};
const getFieldMaxLengthResp = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_resmaxlen'),
value: getSpanProp(span, 'max_length_resp') as string,
});
const getFieldChannel = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_channel'),
value: getSpanProp(span, 'channel') as string,
});
const getFieldInputType = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_inputtype'),
value: getSpanProp(span, 'query_input_method') as string,
});
const getFieldInput = (span: CSpan): FieldItem => ({
key: I18n.t('analytic_query_input'),
value: getSpanProp(span, 'input') as string,
});
const getStreamOutput = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'streaming_output') as StreamingOutputStatus;
// key 为 starling key
return {
key: I18n.t('query_stream_output'),
value: streamingOutputStatusConfigMap[value]?.label,
};
};
const getCardId = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'card_id') as string;
return { key: I18n.t('query_card_id'), value };
};
const getBranchName = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'branch_name') as string;
return { key: 'branch_name', value };
};
const getNodeType = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'node_type') as string;
return { key: 'node_type', value };
};
const getHookType = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'hook_type') as string;
return {
key: I18n.t('codedev_hook_hook_type'),
value,
};
};
const getHookUri = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'hook_uri') as string;
return {
key: 'Hook Uri',
value,
};
};
const getAgentId = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'agent_id') as string;
return {
key: 'AgentId',
value,
};
};
const getHookRespCode = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'hook_resp_code')?.toString() as string;
return {
key: I18n.t('analytic_query_hook_resp_code'),
value,
};
};
const getIsStream = (span: CSpan): FieldItem => {
const value = getSpanProp(span, 'is_stream')?.toString() as string;
return {
key: I18n.t('query_stream_output'),
value,
};
};
export const fieldItemHandlers = {
category: getFieldCategory,
type: getFieldType,
os: getFieldOS,
latency: getFieldLatency,
offline: getFieldOffline,
name: getFieldName,
start_time: getFieldStartTime,
end_time: getFieldEndTime,
call_type: getFieldCallType,
agent_type: getFieldAgentType,
model: getFieldModel,
temperature: getFieldTemperature,
dialog_round: getFieldDialogRound,
max_length_resp: getFieldMaxLengthResp,
channel: getFieldChannel,
input_type: getFieldInputType,
input: getFieldInput,
card_id: getCardId,
stream_output: getStreamOutput,
branch_name: getBranchName,
node_type: getNodeType,
hook_type: getHookType,
hook_uri: getHookUri,
agent_id: getAgentId,
hook_resp_code: getHookRespCode,
is_stream: getIsStream,
};

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.
*/
import dayjs from 'dayjs';
export const formatTime = (timestamp?: number | string) =>
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');

View File

@@ -0,0 +1 @@
公共API目录, 基于bam接口的二次封装

View File

@@ -0,0 +1 @@
公共样式目录

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.
*/
declare module '*.less' {
const resource: { [key: string]: string };
export = resource;
}

View File

@@ -0,0 +1 @@
公共类型目录

View File

@@ -0,0 +1 @@
公共util目录

View File

@@ -0,0 +1,44 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"compilerOptions": {
"types": [],
"strictNullChecks": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"references": [
{
"path": "../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../arch/bot-flags/tsconfig.build.json"
},
{
"path": "../../arch/bot-hooks/tsconfig.build.json"
},
{
"path": "../../arch/i18n/tsconfig.build.json"
},
{
"path": "../../components/bot-icons/tsconfig.build.json"
},
{
"path": "../../components/bot-semi/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["__tests__", "stories", "vitest.config.ts", "tailwind.config.ts"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
],
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"types": ["vitest/globals"],
"strictNullChecks": true
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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 { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
});