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,60 @@
/*
* 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 cls from 'classnames';
import { Typography } from '@coze-arch/coze-design';
export interface IDocumentItemProps {
id: string;
title: string;
selected?: boolean;
onClick?: (id: string) => void;
label?: ReactNode;
tag?: ReactNode;
}
const DocumentItem: React.FC<IDocumentItemProps> = props => {
const { id, onClick, title, selected, tag, label } = props;
return (
<div
className={cls(
'w-full h-8 px-2 py-[6px] rounded-[8px] hover:coz-mg-primary cursor-pointer',
'flex items-center',
selected && 'coz-mg-primary',
)}
onClick={() => onClick?.(id)}
>
{label ? (
<div className="w-full">{label}</div>
) : (
<>
<Typography.Text
ellipsis={{ showTooltip: true }}
className="w-full coz-fg-primary text-[14px] leading-[20px] grow truncate"
>
{title}
</Typography.Text>
<div className="flex items-center shrink-0">{tag}</div>
</>
)}
</div>
);
};
export default DocumentItem;

View File

@@ -0,0 +1,184 @@
/*
* 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, useState } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Search, Tooltip } from '@coze-arch/coze-design';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import MergeOperation from '../assets/merge-operation.png';
import MergeOperationEn from '../assets/merge-operation-en.png';
import LevelOperation from '../assets/level-operation.png';
import DeleteOperation from '../assets/delete-operation.png';
import DeleteOperationEn from '../assets/delete-operation-en.png';
import { SegmentTree } from './segment-tree';
import DocumentItem from './document-item';
interface IMenuItem {
id: string;
title: string;
label?: ReactNode;
tag?: ReactNode;
tosUrl?: string;
}
interface ISegmentMenuProps {
list: IMenuItem[];
onClick?: (id: string) => void;
selectedID?: string;
isSearchable?: boolean;
treeVisible?: boolean;
treeDisabled?: boolean;
levelSegments?: ILevelSegment[];
setLevelSegments?: (segments: ILevelSegment[]) => void;
setSelectionIDs?: (ids: string[]) => void;
}
const SegmentMenu: React.FC<ISegmentMenuProps> = props => {
const {
isSearchable,
list,
onClick,
selectedID,
levelSegments,
setLevelSegments,
setSelectionIDs,
treeDisabled,
treeVisible,
} = props;
const [searchValue, setSearchValue] = useState('');
return (
<div className="flex flex-col grow w-full h-full">
{isSearchable ? (
<Search
value={searchValue}
placeholder={I18n.t('datasets_placeholder_search')}
onChange={setSearchValue}
/>
) : null}
<div className="pl-2 h-6 mt-4 mb-1 flex items-center">
<div className="coz-fg-secondary text-[12px] font-[400] leading-4 shrink-0">
{/**文档列表 */}
{I18n.t('knowledge_level_012')}
</div>
</div>
<div className="flex flex-col grow w-full">
<div className="flex flex-col gap-1 h-[150px] grow !overflow-auto shrink-0">
{list
.filter(item => item.title.includes(searchValue))
.map(document => {
if (document.id !== '') {
return (
<DocumentItem
key={document.id}
id={document.id}
selected={document.id === selectedID}
onClick={onClick}
title={document.title}
tag={document.tag}
label={document.label}
/>
);
} else {
return null;
}
})}
</div>
{levelSegments?.length && treeVisible ? (
<>
<div className="h-4 flex justify-center items-center px-[8px] mb-[8px]">
<div
className={cls(
'border border-solid border-[0.5px] transition w-full',
'coz-stroke-primary',
)}
/>
</div>
<div className="flex flex-col gap-1 !overflow-auto">
<div className="w-full pl-2 h-6 items-center flex gap-[4px]">
<div className="coz-fg-secondary text-[12px] font-[400] leading-4 shrink-0">
{/**分段层级 */}
{I18n.t('knowledge_level_adjust')}
</div>
{treeDisabled ? null : (
<Tooltip
style={{
maxWidth: 602,
}}
position="left"
content={
<>
<div className="coz-fg-plus text-[14px] font-[500] leading-[20px] mb-3">
{I18n.t('knowledge_hierarchies_categories')}
</div>
<div className="flex gap-2">
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_999')}
</span>
<img src={LevelOperation} className="w-[182px]" />
</div>
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_998')}
</span>
<img
src={
IS_OVERSEA ? MergeOperationEn : MergeOperation
}
className="w-[182px]"
/>
</div>
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_997')}
</span>
<img
src={
IS_OVERSEA ? DeleteOperationEn : DeleteOperation
}
className="w-[182px]"
/>
</div>
</div>
</>
}
>
<IconCozInfoCircle className="coz-fg-secondary" />
</Tooltip>
)}
</div>
<div className="h-[360px]">
<SegmentTree
segments={levelSegments}
setLevelSegments={setLevelSegments}
setSelectionIDs={setSelectionIDs}
disabled={treeDisabled}
/>
</div>
</div>
</>
) : null}
</div>
</div>
);
};
export default SegmentMenu;

View File

@@ -0,0 +1,207 @@
/*
* 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 { Tree, type NodeRendererProps, type CursorProps } from 'react-arborist';
import { useState, type CSSProperties } from 'react';
import useResizeObserver from 'use-resize-observer';
import cls from 'classnames';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowRight } from '@coze-arch/coze-design/icons';
import { IconButton, Toast } from '@coze-arch/coze-design';
import {
findDescendantIDs,
getTreeNodes,
handleDeleteNode,
handleMergeNodes,
handleTreeNodeMove,
} from './utils/level-tree-op';
import { useSegmentContextMenu } from './use-context-menu';
import { type LevelDocumentTree } from './types';
interface ISegmentTreeProps {
segments: ILevelSegment[];
setLevelSegments?: (segments: ILevelSegment[]) => void;
setSelectionIDs?: (ids: string[]) => void;
disabled?: boolean;
}
export const SegmentTree: React.FC<ISegmentTreeProps> = ({
segments,
setLevelSegments,
setSelectionIDs,
disabled,
}) => {
/**
* 选中功能
*/
const [selected, setSelected] = useState(new Set<string>());
// 分片 id
const [selectedThroughParent, setSelectedThroughParent] = useState(
new Set<string>(),
);
const { ref, width, height } = useResizeObserver<HTMLDivElement>();
const onSelect = (node: LevelDocumentTree) => {
setSelected(new Set([node.id]));
setSelectedThroughParent(findDescendantIDs(node));
setSelectionIDs?.([node.id, ...findDescendantIDs(node)]);
};
/**
* render
*/
const Node = ({
node,
style,
dragHandle,
}: NodeRendererProps<LevelDocumentTree>) => {
const { isOpen, data } = node;
const isLeaf = !data.children?.length;
const expandIcon = (
<IconButton
size="small"
color="secondary"
icon={
<IconCozArrowRight
className={cls(
isOpen && 'rotate-90',
'transition duration-150 ease-in-out',
)}
/>
}
onClick={e => {
e.stopPropagation();
node.toggle();
}}
className={cls('bg-transparent ml-[4px] shrink-0')}
/>
);
return (
<div
className={cls(
'flex items-center gap-[4px]',
'h-[32px] py-[4px] pr-[8px] mb-[2px]',
'hover:coz-mg-primary cursor-pointer',
'transition duration-150 ease-in-out',
'rounded-[8px]',
(selected.has(data.id) || selectedThroughParent.has(data.id)) &&
'coz-mg-primary',
)}
onClick={() => {
onSelect(data);
}}
onContextMenu={e => {
if (disabled) {
return;
}
onContextMenu(e, node);
}}
style={style}
ref={dragHandle}
>
{isLeaf ? <span className="w-6 ml-[4px] shrink-0" /> : expandIcon}
<span
className={cls('text-[14px] leading-[20px] coz-fg-primary truncate')}
>
{data.type !== 'image'
? data.text.slice(0, 50)
: I18n.t('knowledge_level_110')}
</span>
</div>
);
};
/**
* context menu
*/
const { popoverNode, onContextMenu } = useSegmentContextMenu({
onMerge: node => {
const { segments: newSegments, errMsg } = handleMergeNodes(
node.id,
Array.from(findDescendantIDs(node)),
segments,
);
if (errMsg) {
Toast.error(errMsg);
}
if (newSegments?.length) {
setLevelSegments?.(newSegments);
}
},
onDelete: node => {
const newSegments = handleDeleteNode(
[node.id, ...findDescendantIDs(node)],
segments,
);
setLevelSegments?.(newSegments);
},
});
return (
<div ref={ref} className="w-full h-full relative translate-z-0">
<Tree
data={getTreeNodes(segments)}
disableDrag={disabled}
disableDrop={disabled}
onMove={({ dragIds, parentId, index }) => {
const { segments: newSegments, errMsg } = handleTreeNodeMove(
{ dragIDs: dragIds, parentID: parentId, dropIndex: index },
segments,
);
if (errMsg) {
Toast.error(errMsg);
}
if (newSegments?.length) {
setLevelSegments?.(newSegments);
}
}}
rowHeight={34}
paddingTop={4}
paddingBottom={4}
width={width}
height={height}
renderCursor={Cursor}
>
{Node}
</Tree>
{popoverNode}
</div>
);
};
const Cursor = ({ top, left, indent }: CursorProps) => {
const placeholderStyle = {
display: 'flex',
alignItems: 'center',
zIndex: 1,
};
const style: CSSProperties = {
position: 'absolute',
pointerEvents: 'none',
top: `${top - 2}px`,
left: `${left}px`,
right: `${indent}px`,
};
return (
<div style={{ ...placeholderStyle, ...style }}>
<div className={cls('flex-1 h-[2px] coz-mg-hglt-plus')}></div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
/*
* 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 ILevelSegment } from '@coze-data/knowledge-stores';
export type LevelDocumentTree = Omit<
ILevelSegment,
'children' | 'id' | 'parent'
> & {
id: string;
parent: string;
children: LevelDocumentTree[];
};

View File

@@ -0,0 +1,136 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type NodeApi } from 'react-arborist';
import { useState } from 'react';
import { useKnowledgeParams } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { Menu, MenuSubMenu } from '@coze-arch/coze-design';
import { type LevelDocumentTree } from './types';
interface IUseSegmentContextMenuProps {
onDelete: (node: LevelDocumentTree) => void;
onMerge: (node: LevelDocumentTree) => void;
}
export function useSegmentContextMenu({
onDelete,
onMerge,
}: IUseSegmentContextMenuProps): {
popoverNode: React.ReactNode;
onContainerScroll: () => void;
onContextMenu: (
e: React.MouseEvent<HTMLDivElement>,
treeNode: NodeApi<LevelDocumentTree>,
) => void;
} {
const [treeNode, setTreeNode] = useState<NodeApi<LevelDocumentTree> | null>();
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const params = useKnowledgeParams();
return {
popoverNode: (
<Menu
visible={visible}
onVisibleChange={setVisible}
onClickOutSide={() => {
setVisible(false);
setTreeNode(null);
}}
trigger="custom"
position="bottomLeft"
render={
<MenuSubMenu mode="menu">
{treeNode && !treeNode.children?.length ? (
<>
<Menu.Item
isMenu
onClick={() => {
onDelete(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_028')}
</Menu.Item>
</>
) : null}
{treeNode && treeNode.children?.length ? (
<>
<Menu.Item
isMenu
onClick={() => {
onMerge(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_029')}
</Menu.Item>
<Menu.Item
isMenu
onClick={() => {
onDelete(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_028')}
</Menu.Item>
</>
) : null}
</MenuSubMenu>
}
>
<div
style={{
height: 0,
width: 0,
position: 'fixed',
top: position.top,
left: position.left,
}}
/>
</Menu>
),
onContainerScroll: () => {
if (visible) {
setVisible(false);
}
},
onContextMenu: (e, node: NodeApi<LevelDocumentTree>) => {
e.preventDefault();
setTreeNode(node);
/** 在 project ide 里面ide 容器设置了 contain: strict, 会导致 fixed position
* 的偏移基础不对,所以这里需要减去 ide 容器的 left 和 top 值
*/
let clickX = e.pageX;
let clickY = e.pageY;
const ideDom = document.getElementById(
`coze-project:///knowledge/${params.datasetID}`,
);
if (ideDom) {
const { left, top } = ideDom.getBoundingClientRect();
clickX = clickX - left;
clickY = clickY - top;
}
setPosition({ left: clickX, top: clickY });
setVisible(true);
},
};
}

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 { cloneDeep } from 'lodash-es';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { type LevelDocumentTree } from '../types';
export const getTreeNodes = (
segments: ILevelSegment[],
): LevelDocumentTree[] => {
const root = segments.find(f => f.parent === -1 && f.type === 'title');
if (!root) {
return segments.map(item => ({
...item,
id: item.id.toString(),
parent: item.parent?.toString(),
children: [],
}));
}
return [
{
...root,
id: root.id?.toString(),
parent: root.parent?.toString(),
children: getChildren(root, segments),
},
];
};
/** Segments to TreeNodes */
const getChildren = (
target: ILevelSegment,
list: ILevelSegment[],
): LevelDocumentTree[] =>
(target.children ?? []).reduce<LevelDocumentTree[]>((acc, cur) => {
const found = list.find(f => f.id === cur);
if (found) {
return [
...acc,
{
...found,
id: found.id?.toString(),
parent: found.parent?.toString(),
children: getChildren(found, list),
},
];
} else {
return [...acc];
}
}, []);
/**TreeNodes related */
export const findDescendantIDs = (node: LevelDocumentTree) => {
const ids = new Set<string>();
const findChild = (item: LevelDocumentTree) => {
if (!item || !item.id) {
return;
}
const { children } = item;
if (children && children.length) {
children.forEach(child => {
if (child && child.id) {
ids.add(child.id);
findChild(child);
}
});
}
};
findChild(node);
return ids;
};
export const findTreeNodeByID = (
nodes: LevelDocumentTree[],
id: string,
): LevelDocumentTree | null => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = findTreeNodeByID(node.children, id);
if (found) {
return found;
}
}
}
return null;
};
export const handleTreeNodeMove = (
positions: { dragIDs: string[]; parentID: string | null; dropIndex: number },
segments: ILevelSegment[],
): {
segments: ILevelSegment[] | null;
errMsg: string | null;
} => {
if (positions.parentID === null) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_01'),
};
}
const resSegments = cloneDeep(segments);
for (const id of positions.dragIDs) {
const dragSegmentIdx = resSegments.findIndex(
segment => segment.id.toString() === id,
);
if (dragSegmentIdx === -1) {
continue;
}
const dragSegment = resSegments[dragSegmentIdx];
const parentSegment = resSegments.find(
segment => segment.id.toString() === positions.parentID,
);
if (!parentSegment) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_02'),
};
}
// 如果是同一个 parent且拖动的位置在当前位置之前dropIndex 减 1
const originalIndex = parentSegment.children.indexOf(dragSegment.id);
const dropIndex =
originalIndex < positions.dropIndex &&
dragSegment.parent === parentSegment.id
? positions.dropIndex - 1
: positions.dropIndex;
if (dragSegment.parent !== parentSegment.id) {
// Remove from old parent's children
const oldParent = resSegments.find(s => s.id === dragSegment.parent);
oldParent?.children.splice(oldParent.children.indexOf(dragSegment.id), 1);
dragSegment.parent = parentSegment.id;
}
// Reorder in parent's children
parentSegment.children = parentSegment.children.filter(
child => child !== dragSegment.id,
);
parentSegment.children.splice(dropIndex, 0, dragSegment.id);
}
return {
segments: resSegments,
errMsg: null,
};
};
export const handleDeleteNode = (ids: string[], segments: ILevelSegment[]) => {
const resSegments = cloneDeep(segments);
for (const id of ids) {
const index = resSegments.findIndex(item => item.id.toString() === id);
const parentSegment = resSegments.find(
item => item.id === resSegments[index].parent,
);
if (parentSegment) {
parentSegment.children = parentSegment.children.filter(
item => item !== resSegments[index].id,
);
}
resSegments.splice(index, 1);
}
return resSegments;
};
export const handleMergeNodes = (
id: string,
descendants: string[],
segments: ILevelSegment[],
): {
segments: ILevelSegment[] | null;
errMsg: string | null;
} => {
const resSegments = cloneDeep(segments);
const mergedSegment = resSegments.find(item => item.id.toString() === id);
if (!mergedSegment) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_03'),
};
}
if (mergedSegment.parent === -1 && mergedSegment.type === 'title') {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_03'),
};
}
mergedSegment.children = [];
mergedSegment.type = 'section-text';
for (const descendant of descendants) {
const segmentToMerge = resSegments.find(
item => item.id.toString() === descendant,
);
if (!segmentToMerge) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_04'),
};
}
// 从原父节点的 children 中移除该节点
const parentSegment = resSegments.find(
item => item.id === segmentToMerge.parent,
);
if (parentSegment) {
parentSegment.children = parentSegment.children.filter(
childId => childId !== segmentToMerge.id,
);
}
if (!['table', 'image', 'title'].includes(segmentToMerge?.type ?? '')) {
// 合并文本内容并删除节点
mergedSegment.text += segmentToMerge.text;
const index = resSegments.findIndex(
item => item.id === segmentToMerge.id,
);
if (index !== -1) {
resSegments.splice(index, 1);
}
} else {
// 非section-text类型的节点将其移动到合并后节点的children中
segmentToMerge.parent = mergedSegment.id;
mergedSegment.children.push(segmentToMerge.id);
}
}
return {
segments: resSegments,
errMsg: null,
};
};