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,16 @@
# @coze-data/knowledge-common-components
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

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

View File

@@ -0,0 +1,9 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {
'@typescript-eslint/naming-convention': 'off',
},
});

View File

@@ -0,0 +1,91 @@
{
"name": "@coze-data/knowledge-common-components",
"version": "0.0.1",
"description": "@coze-data/knowledge-common-components",
"license": "Apache-2.0",
"author": "haozhenfei@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.tsx",
"./file-picker": "./src/file-picker/index.tsx",
"./text-knowledge-editor": "./src/text-knowledge-editor/index.tsx",
"./text-knowledge-editor/*": "./src/text-knowledge-editor/*/index.tsx"
},
"main": "src/index.tsx",
"typesVersions": {
"*": {
"file-picker": [
"./src/file-picker/index.tsx"
],
"text-knowledge-editor": [
"./src/text-knowledge-editor/index.tsx"
],
"text-knowledge-editor/*": [
"./src/text-knowledge-editor/*/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-error": "workspace:*",
"@coze-arch/bot-md-box-adapter": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/report-events": "workspace:*",
"@coze-common/virtual-list": "workspace:*",
"@coze-data/e2e": "workspace:*",
"@coze-data/feature-register": "workspace:*",
"@coze-data/knowledge-stores": "workspace:*",
"@coze-data/reporter": "workspace:*",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-hard-break": "^2.12.0",
"@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"dompurify": "3.0.8",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nanoid": "^4.0.2",
"react-arborist": "^3.4.0",
"react-pdf": "9.1.1",
"use-resize-observer": "^9.1.0"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -0,0 +1,8 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.832031 5H19.1654V15.8333C19.1654 16.7538 18.4192 17.5 17.4987 17.5H2.4987C1.57822 17.5 0.832031 16.7538 0.832031 15.8333V5Z"
fill="#FFC60A" />
<path
d="M0.832031 3.33329C0.832031 2.41282 1.57822 1.66663 2.4987 1.66663H6.89425C7.29396 1.66663 7.6904 1.73852 8.06466 1.87886L10.2661 2.70439C10.6403 2.84474 11.0368 2.91663 11.4365 2.91663H17.4987C18.4192 2.91663 19.1654 3.66282 19.1654 4.58329V4.99996H0.832031V3.33329Z"
fill="#FF9D4C" />
</svg>

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@@ -0,0 +1,14 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#35BD4B" />
<path
d="M5.625 7.43201H12.3864C13.4847 7.43201 14.375 8.32235 14.375 9.42064V16.182H7.61364C6.51534 16.182 5.625 15.2917 5.625 14.1934V7.43201ZM10.5638 8.62519V11.2104H13.1818V9.42064C13.1818 8.98133 12.8257 8.62519 12.3864 8.62519H10.5638ZM9.37066 8.62519H6.81818V11.2104H9.37066V8.62519ZM6.81818 12.4036V14.1934C6.81818 14.6327 7.17432 14.9888 7.61364 14.9888H9.37066V12.4036H6.81818ZM10.5638 14.9888H13.1818V12.4036H10.5638V14.9888Z"
fill="white" />
<path opacity="0.7"
d="M12.5 1.23574C12.5 1.08726 12.6795 1.0129 12.7845 1.11789L17.2155 5.54886C17.3205 5.65385 17.2461 5.83337 17.0976 5.83337H14.1667C13.2462 5.83337 12.5 5.08718 12.5 4.16671V1.23574Z"
fill="#298C38" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M5.83203 13.5773C5.83203 13.2321 6.11185 12.9523 6.45703 12.9523H11.0404C11.3855 12.9523 11.6654 13.2321 11.6654 13.5773C11.6654 13.9224 11.3855 14.2023 11.0404 14.2023H6.45703C6.11185 14.2023 5.83203 13.9224 5.83203 13.5773Z"
fill="white" />
<path
d="M6.45703 9.20227C6.11185 9.20227 5.83203 9.48209 5.83203 9.82727C5.83203 10.1724 6.11185 10.4523 6.45703 10.4523H13.5404C13.8855 10.4523 14.1654 10.1724 14.1654 9.82727C14.1654 9.48209 13.8855 9.20227 13.5404 9.20227H6.45703Z"
fill="white" />
<path opacity="0.7"
d="M12.5 1.23574C12.5 1.08726 12.6795 1.0129 12.7845 1.11789L17.2155 5.54886C17.3205 5.65385 17.2461 5.83337 17.0976 5.83337H14.1667C13.2462 5.83337 12.5 5.08718 12.5 4.16671V1.23574Z"
fill="#0442D2" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { Spin } from '@coze-arch/coze-design';
interface IPreviewMdProps {
fileUrl: string;
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const PreviewMd = (props: IPreviewMdProps) => {
const { fileUrl } = props;
const [mdContent, setMdContent] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(fileUrl)
.then(res => res.text())
.then(text => {
setLoading(false);
setMdContent(text);
});
}, [fileUrl]);
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
async function render() {
if (ref.current) {
for (
let i = 0, len = mdContent.length;
i < Math.ceil(len / 50_000);
i++
) {
await wait(10);
ref.current.textContent += mdContent.slice(
i * 50_000,
(i + 1) * 50_000,
);
}
ref.current.textContent = mdContent;
}
}
render();
}, [mdContent]);
return (
<div className="flex flex-col items-center w-full h-full flex-1 py-2 px-4">
<Spin
wrapperClassName="w-full h-full grow"
spinning={loading}
childStyle={{
width: '100%',
height: '100%',
flexGrow: 1,
}}
>
{mdContent ? (
<div>
<pre
className="max-w-full overflow-auto whitespace-pre-wrap break-all text-[14px] leading-[22px]"
ref={ref}
>
{/* {mdContent} */}
</pre>
</div>
) : null}
</Spin>
</div>
);
};

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { Spin } from '@coze-arch/coze-design';
interface IPreviewTxtProps {
fileUrl: string;
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const PreviewTxt = (props: IPreviewTxtProps) => {
const { fileUrl } = props;
const [txtContent, setTxtContent] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(fileUrl)
.then(res => res.text())
.then(text => {
setLoading(false);
setTxtContent(text);
});
}, [fileUrl]);
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
async function render() {
if (ref.current) {
for (
let i = 0, len = txtContent.length;
i < Math.ceil(len / 50_000);
i++
) {
await wait(10);
if (ref.current) {
ref.current.textContent += txtContent.slice(
i * 50_000,
(i + 1) * 50_000,
);
}
}
if (ref.current) {
ref.current.textContent = txtContent;
}
}
}
render();
}, [txtContent]);
return (
<div className="flex flex-col items-center w-full h-full flex-1 py-2 px-4">
<Spin
wrapperClassName="w-full h-full grow"
spinning={loading}
childStyle={{
width: '100%',
height: '100%',
flexGrow: 1,
}}
>
<pre
className="max-w-full overflow-auto whitespace-pre-wrap break-all text-[14px] leading-[22px]"
ref={ref}
>
{/* {txtContent} */}
</pre>
</Spin>
</div>
);
};

View File

@@ -0,0 +1,200 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import { Document, Page, pdfjs } from 'react-pdf';
import { useMemo, useRef, useState } from 'react';
import cls from 'classnames';
import { FixedSizeList as List, AutoSizer } from '@coze-common/virtual-list';
import { Spin } from '@coze-arch/coze-design';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
interface IUsePreviewPdfProps {
fileUrl: string;
}
pdfjs.GlobalWorkerOptions.workerSrc =
REGION === 'cn'
? // cp-disable-next-line
`//lf-cdn.coze.cn/obj/unpkg/pdfjs-dist/${pdfjs.version}/build/pdf.worker.min.mjs`
: // cp-disable-next-line
`//sf-cdn.coze.com/obj/unpkg-va/pdfjs-dist/${pdfjs.version}/build/pdf.worker.min.mjs`;
const options = {
cMapUrl:
REGION === 'cn'
? // cp-disable-next-line
`//lf-cdn.coze.cn/obj/unpkg/pdfjs-dist/${pdfjs.version}/cmaps/`
: // cp-disable-next-line
`//sf-cdn.coze.com/obj/unpkg-va/pdfjs-dist/${pdfjs.version}/cmaps/`,
// 提升性能
cMapPacked: true,
};
export const usePreviewPdf = (props: IUsePreviewPdfProps) => {
const { fileUrl } = props;
const [loading, setLoading] = useState(true);
const [numPages, setNumPages] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<List>(null);
const [pageHeight, setPageHeight] = useState(
containerRef.current?.clientHeight,
);
const itemSize = Math.floor((pageHeight ?? 500) + 20);
const onNext = () => {
if (currentPage < numPages - 1) {
const nextPage = currentPage + 1;
setCurrentPage(nextPage);
listRef.current?.scrollToItem(nextPage, 'start');
}
};
const onBack = () => {
if (currentPage > 0) {
const prevPage = currentPage - 1;
setCurrentPage(prevPage);
listRef.current?.scrollToItem(prevPage, 'start');
}
};
const onDocumentLoadSuccess = ({
numPages: totalPages,
}: {
numPages: number;
}) => {
setLoading(false);
setNumPages(totalPages);
};
const handleScroll = ({ scrollOffset }: { scrollOffset: number }) => {
const newIndex = Math.floor(scrollOffset / itemSize);
setCurrentPage(newIndex);
};
const [scale, setScale] = useState(1);
const increaseScale = () => {
setScale(prevScale => Math.min(prevScale + 0.1, 2));
};
const decreaseScale = () => {
setScale(prevScale => Math.max(prevScale - 0.1, 0.5));
};
const memoizedList = useMemo(
() =>
!loading ? (
<AutoSizer>
{({ height, width }) => (
<List
height={height ?? 0}
itemCount={numPages}
itemSize={itemSize}
width={width ?? 0}
onScroll={handleScroll}
ref={listRef}
>
{({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => (
<div key={`page_${index + 1}`} style={style}>
<Page
pageNumber={index + 1}
className={cls(
'flex items-center justify-center !coz-bg-primary',
)}
width={(width ?? 100) - 32}
scale={scale}
onLoadSuccess={page => {
setPageHeight(page.height);
}}
loading={
<div
style={{
height: containerRef.current?.clientHeight,
}}
></div>
}
/>
</div>
)}
</List>
)}
</AutoSizer>
) : null,
[loading, numPages, pageHeight, scale, itemSize],
);
const pdfNode = (
<>
<div className="flex flex-col items-center w-full h-full relative coz-bg-primary">
<div
className={cls(
'absolute top-0 left-0 right-0 bot-0 flex items-center justify-center h-full',
'z-10',
!loading && 'invisible',
)}
>
<Spin />
</div>
<div
className={cls(
'flex absolute top-0 left-0 right-0 overflow-auto px-5 w-full h-full justify-center',
loading && 'invisible',
)}
ref={containerRef}
>
<Document
file={fileUrl}
// bug fix https://github.com/wojtekmaj/react-pdf/issues/974
key={fileUrl}
onLoadSuccess={onDocumentLoadSuccess}
options={options}
className={cls('flex w-full h-full')}
loading={
<Spin
wrapperClassName="w-full h-full"
childStyle={{
width: containerRef.current?.clientWidth,
height: '100%',
}}
></Spin>
}
>
{containerRef.current ? memoizedList : null}
</Document>
</div>
</div>
</>
);
return {
pdfNode,
numPages,
currentPage: currentPage + 1,
onNext,
onBack,
scale,
increaseScale,
decreaseScale,
};
};

View File

@@ -0,0 +1,152 @@
/* stylelint-disable declaration-no-important */
.expand-icon {
transform: rotate(90deg) !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin: 0 !important;
line-height: 20px;
}
.common-file-picker-wrapper {
:global {
.semi-tree-option-selected {
background-color: var(--coz-mg-hglt-hovered) !important;
&:hover {
background-color: unset;
}
}
.semi-tree-option-disable {
cursor: not-allowed;
}
.semi-tree-option-readonly {
cursor: default;
&:hover{
background: transparent !important;
}
}
.semi-tree-option {
height: 32px !important;
margin: 1px 0 !important;
line-height: 32px !important;
border-radius: 4px;
&:hover {
background-color: var(--coz-mg-secondary-hovered);
}
}
.semi-tree-option-expand-icon {
.expand-icon();
}
.expand-placeholder {
.expand-icon();
}
.semi-tree-option-list .semi-tree-option-collapsed .semi-tree-option-expand-icon {
transform: unset !important;
}
.file-selector {
width: 16px;
height: 16px;
margin: 0 12px;
.semi-checkbox-inner {
height: 16px;
}
.semi-radio {
height: 16px;
min-height: 16px;
line-height: 16px;
vertical-align: top;
.semi-radio-inner {
margin-top: 0;
}
}
}
.file-node-row-content {
display: flex;
flex: 1;
align-items: center;
height: 36px;
margin: 2px 0;
padding: 8px 13px;
border-radius: 4px;
}
.file-icon {
display: flex;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
line-height: 20px;
.semi-spin-middle>.semi-spin-wrapper svg {
width: 16px;
height: 16px;
}
img {
width: 20px;
height: 20px;
line-height: 20px;
}
}
.file-content {
display: flex;
flex: 1;
justify-content: space-between;
height: 20px;
line-height: 20px;
.file-name {
overflow: hidden;
width: 80%;
height: 20px;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--coz-fg-primary);
white-space: nowrap;
}
.file-loading-info {
height: 20px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--coz-fg-hglt);
}
}
}
}

View File

@@ -0,0 +1,332 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Tree } from '@coze-arch/coze-design';
import type {
TreeProps,
RenderFullLabelProps,
} from '@coze-arch/bot-semi/Tree';
import { CommonE2e } from '@coze-data/e2e';
import { distinctFileNodes, levelMapTreeNodesToMap } from '../utils';
import {
isFileNodeArray,
type FileNode,
type PickerRef,
type TransSelectedFilesMiddleware,
type FileId,
type CalcCurrentSelectFilesMiddleware,
} from '../types';
import { useDefaultLabelRenderer } from '../hooks/useDefaultLabelRenderer';
import {
DEFAULT_FILENODE_LEVEL_INDENT,
DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
DEFAULT_VIRTUAL_ITEM_HEIGHT,
} from '../consts';
import styles from './common-file-picker.module.less';
export interface CommonFilePickerProps
extends Omit<TreeProps, 'onChange' | 'onSelect'> {
/** 渲染使用的树数据 */
treeData: FileNode[];
/** 提供一个定制化的 render tree node */
customTreeDataRenderer?: (
renderProps: RenderFullLabelProps,
) => React.ReactNode;
/** 是否只能选中叶子结点 如果传入 customRenderTreeData 则失效 */
onlySelectLeaf?: boolean;
/** 是否多选 如果自定义 customTreeDataRenderer 一定要传入, 选项会影响 customTreeDataRenderer 的入参 */
multiple?: boolean;
/**
* 是否开启虚拟化
*/
enableVirtualize?: boolean;
/** 虚拟化选项 */
/** 虚拟化容器高度 */
virtualizeHeight?: number;
/** 每个 item 高度 */
virtualizeItemSize?: number;
/** 默认已经选中的内容 可以作为 initValue 使用,也可以作为 value 的代替者使用 */
defaultValue?: FileNode[] | FileId[];
/** 样式渲染特性 */
normalLabelStyle?: React.CSSProperties;
selectedLabelStyle?: React.CSSProperties;
halfSelectedLabelStyle?: React.CSSProperties;
/** 缩进大小 默认大小 25px 如果 backgroundMode 为 position 将会反应为 left 如果为 padding 将会反应成 padding-left */
indentSize?: number;
/** 树组件展开的 icon */
expandIcon?: React.ReactNode;
/** onChange 业务层可以透传 */
onChange?: (args?: FileNode[]) => void;
/** onSelect 业务层可以透传 */
onSelect?: (key: string, selected: boolean, node: FileNode) => void;
/** 用来转换 selectedFiles, 发生在 设置选中态 到 提交给上层组件 之间 注意 处理有先后顺序,前一个中间件的返回将作为后一个的输入 */
transSelectedFilesMiddlewares?: TransSelectedFilesMiddleware[];
/** 设置选择态的钩子 发生在 点击选中框 到 设置选择态 之间 处理有先后顺序 */
selectFilesMiddlewares?: CalcCurrentSelectFilesMiddleware[];
/** disable select 禁止选择 */
disableSelect?: boolean;
/** checkRelation: 父子节点选中态是否关联 */
checkRelation?: 'related' | 'unRelated';
/** 默认展开的节点 key */
defaultExpandKeys?: FileId[];
}
function diffChangeNodes(
prevChangeNodes: FileNode[],
changeNodes: FileNode[],
): [FileNode[], FileNode[], FileNode[]] {
const addNodes: FileNode[] = [];
const removeNodes: FileNode[] = [];
const retainNodes: FileNode[] = [];
const prevChangeKeysSet = new Set(prevChangeNodes.map(node => node.key));
const changeKeysSet = new Set(changeNodes.map(node => node.key));
for (const changeNode of prevChangeNodes) {
if (!changeKeysSet.has(changeNode.key)) {
removeNodes.push(changeNode);
}
}
for (const changeNode of changeNodes) {
if (prevChangeKeysSet.has(changeNode.key)) {
retainNodes.push(changeNode);
} else {
addNodes.push(changeNode);
}
}
return [addNodes, removeNodes, retainNodes];
}
function getFirstKeyOfDefaultValue(defaultValue?: FileId[] | FileNode[]) {
if (!defaultValue || defaultValue.length === 0) {
return '';
}
if (typeof defaultValue[0] === 'string') {
return defaultValue[0];
}
return defaultValue[0].key;
}
function transDefaultValueToFileNodes(
treeData: FileNode[],
defaultValue?: FileId[] | FileNode[],
): FileNode[] {
const treeDataMap = levelMapTreeNodesToMap(treeData);
return (
defaultValue?.map(element => {
// 因为开了 onChangeWithObject 所以这里选中态要用 object 存储
if (typeof element === 'string') {
return (
treeDataMap.get(element) ?? {
key: element,
}
);
}
return element;
}) ?? []
);
}
function transDefaultValueToRenderNode(defaultValue?: FileId[] | FileNode[]) {
return (
defaultValue?.map(element => {
// 因为开了 onChangeWithObject 所以这里选中态要用 object 存储
if (typeof element === 'string') {
return {
key: element,
};
}
return element;
}) ?? []
);
}
/**
* ------------------
* common file picker
* 用于数据上传选择文件
* ------------------
* useImperativeHandle:
* search: 提供树搜索能力
* ------------------
* props: FilePickerProps
* ------------------
*/
export const CommonFilePicker = React.forwardRef(
(props: CommonFilePickerProps, ref: React.ForwardedRef<PickerRef>) => {
const {
treeData,
customTreeDataRenderer,
onlySelectLeaf,
multiple,
virtualizeHeight,
virtualizeItemSize,
indentSize,
expandIcon,
onChange,
transSelectedFilesMiddlewares,
defaultValue,
selectFilesMiddlewares = [],
disableSelect = false,
checkRelation = 'related',
defaultExpandKeys = [],
enableVirtualize = true,
} = props;
const treeRef = useRef<Tree>(null);
// 使用受控模式
const [selectValue, setSelectValue] = useState<FileNode[]>(
transDefaultValueToRenderNode(defaultValue),
);
const [expandedKeys, setExpandedKeys] =
useState<string[]>(defaultExpandKeys);
const prevChangeNodes = useRef<FileNode[]>([]);
useEffect(() => {
const defaultFileNodes = transDefaultValueToFileNodes(
treeData,
defaultValue,
);
setSelectValue(transDefaultValueToRenderNode(defaultValue));
prevChangeNodes.current = defaultFileNodes;
}, [defaultValue]);
const renderTreeData = useDefaultLabelRenderer(
!!multiple,
!!onlySelectLeaf,
{
indentSize: indentSize ?? DEFAULT_FILENODE_LEVEL_INDENT,
expandIcon,
disableSelect,
defaultSingleSelectKey: !multiple
? getFirstKeyOfDefaultValue(defaultValue)
: '',
},
);
useImperativeHandle(ref, () => ({
search: treeRef.current?.search,
}));
return (
<div className={styles['common-file-picker-wrapper']}>
<Tree
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
data-testid={CommonE2e.CommonFilePicker}
checkRelation={checkRelation}
value={selectValue}
treeData={treeData}
ref={treeRef}
renderFullLabel={
customTreeDataRenderer ??
(renderTreeData as (
renderProps: RenderFullLabelProps,
) => React.ReactNode)
}
multiple={multiple}
virtualize={
enableVirtualize
? {
height: virtualizeHeight ?? DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
itemSize: virtualizeItemSize ?? DEFAULT_VIRTUAL_ITEM_HEIGHT,
}
: undefined
}
expandedKeys={expandedKeys}
onExpand={(currentExpandedKeys, current) => {
setExpandedKeys(currentExpandedKeys);
}}
onChangeWithObject
onChange={changeNodes => {
let transedChangeNodes: FileNode[];
if (multiple) {
if (
!changeNodes ||
!Array.isArray(changeNodes) ||
!isFileNodeArray(changeNodes)
) {
return;
}
transedChangeNodes = changeNodes;
} else {
if (!changeNodes) {
return;
}
transedChangeNodes = [changeNodes as unknown as FileNode];
}
// 计算 diff
const [addNodes, removeNodes, retainNodes] = diffChangeNodes(
prevChangeNodes.current,
transedChangeNodes as FileNode[],
);
// 这里的中间件更多用在定制化选中态的场景 比如想要反选所有子节点但是保持父亲节点选中
transedChangeNodes = distinctFileNodes(
selectFilesMiddlewares.reduce(
(selectedElements, middleware) =>
middleware(
selectedElements,
addNodes,
removeNodes,
retainNodes,
),
transedChangeNodes,
),
);
prevChangeNodes.current = transedChangeNodes;
setSelectValue(
transedChangeNodes.map(transedNode => ({
key: transedNode.key,
})),
);
// 虽然这里返回的是父节点带 children 但是因为都是后端一次接口的快照
// 具体上报什么数据交给业务方
// 这里的中间件主要用在定制化上报给上层组件的选中数据的场景,比如 checkRelation 'related' 模式下 上面返回的只有父亲节点的数据,但是父组件想要所有数据
// 警告:这里如果使用 loadData 时拿不到没请求的子节点(换句话说拿到的最后一层的数据不一定是叶子结点, 同样的在这里选中之后交回给后端的其实也只是一个父节点 不能保证在一个快照里
if (transSelectedFilesMiddlewares) {
transedChangeNodes = transSelectedFilesMiddlewares.reduce(
(selectedElements, middleware) => middleware(selectedElements),
transedChangeNodes,
);
}
onChange?.(transedChangeNodes);
}}
/>
</div>
);
},
);

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.
*/
// 缩进相关数据
export const DEFAULT_FILENODE_LEVEL_INDENT = 24;
// 虚拟化配置相关数据
export const DEFAULT_VIRTUAL_CONTAINER_HEIGHT = 540;
export const DEFAULT_VIRTUAL_ITEM_HEIGHT = 28;

View File

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

View File

@@ -0,0 +1,389 @@
/*
* 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 complexity */
/* eslint-disable @coze-arch/max-line-per-function */
import React, { useState } from 'react';
import type { RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { Checkbox, Radio, Spin, Typography } from '@coze-arch/bot-semi';
import { ReactComponent as Text } from '@/assets/text-file.svg';
import { ReactComponent as Sheet } from '@/assets/sheet-file.svg';
import { ReactComponent as Folder } from '@/assets/folder.svg';
import {
TreeNodeType,
type FileSelectCheckStatus,
type FileNode,
} from '../types';
const noRealExpandClassName = 'no-real-expand';
const ActionComponent = (props: {
checkStatus: Partial<FileSelectCheckStatus>;
isLeaf: boolean;
renderExpandIcon: React.ReactElement;
onlySelectLeaf: boolean;
multiple: boolean;
disableSelect: boolean;
unCheckable: boolean;
}) => {
const {
checkStatus,
isLeaf,
renderExpandIcon,
onlySelectLeaf,
multiple,
disableSelect,
unCheckable = false,
} = props;
const getSingalSelectAction = (className: string) => (
<span
role="radio"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Radio checked={checkStatus.checked} disabled={disableSelect} />
</span>
);
const getMultiSelectAction = (className: string) => (
<span
role="checkbox"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Checkbox
indeterminate={checkStatus.halfChecked}
checked={checkStatus.checked}
disabled={disableSelect}
/>
</span>
);
const expandPlaceHolder = <span className="expand-placeholder"></span>;
const selectActionPlaceHolder = (
<span className="action-placeholder file-selector"></span>
);
// 当前整棵树是多选 并且 只能选中叶子结点
if (multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getMultiSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
// 当前整棵树是多选 并且 能选中所有结点
if (multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getMultiSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
// 当前整棵树是单选 并且 只能选中叶子结点
if (!multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getSingalSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
// 当前整棵树是单选 并且 能选中所有结点
if (!multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getSingalSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
return <></>;
};
const LabelContent = (props: {
iconUrl?: string;
label: React.ReactNode;
type?: TreeNodeType;
isLoading?: boolean;
loadingInfo?: string;
}) => {
const { iconUrl, label, type, isLoading, loadingInfo } = props;
return (
<>
<span className="file-icon">
{isLoading ? (
<Spin spinning />
) : iconUrl ? (
<img src={iconUrl} />
) : (
{
[TreeNodeType.FILE_TABLE]: <Sheet />,
[TreeNodeType.FOLDER]: <Folder />,
[TreeNodeType.FILE_TEXT]: <Text />,
}[type ?? TreeNodeType.FILE_TEXT]
)}
</span>
<span className="file-content">
<Typography.Text
ellipsis={{
showTooltip: {
type: 'tooltip',
opts: {
style: {
maxWidth: '800px',
},
},
},
}}
className="file-name"
>
{label}
</Typography.Text>
{isLoading ? (
<span className="file-loading-info">{loadingInfo}</span>
) : null}
</span>
</>
);
};
/**
* -----------------------------
* 获取默认的文件树 label renderer 这层不感知平台信息
* -----------------------------
* @param {boolean} multiple 是否多选
* @param {boolean} onlySelectLeaf 是否只能选中叶子结点
* @param {{indentSize: 树组件缩进尺寸, expandIcon: 树组件可展开节点 展开图标, disableSelect: 选择的 disable 状态}} renderOption 渲染相关的自定义选项
* @returns label render 函数
*/
export function useDefaultLabelRenderer(
multiple: boolean,
onlySelectLeaf: boolean,
renderOption: {
indentSize: number;
expandIcon?: React.ReactNode;
disableSelect?: boolean;
defaultSingleSelectKey?: string;
},
) {
const [singleSelectedKey, setSingleSelectKey] = useState(
renderOption.defaultSingleSelectKey ?? '',
);
const getExpandIcon = (
labelDefaultIcon: React.ReactElement,
customIcon?: React.ReactNode,
) => {
if (!customIcon) {
return labelDefaultIcon;
}
const {
props: { onClick, className, role },
} = labelDefaultIcon;
return (
<span
role={role}
className={`${className} semi-tree-option-expand-icon`}
onClick={onClick}
>
{customIcon}
</span>
);
};
const getFileContentClassName = (isChecked: boolean) =>
`w-full file-node-row-content flex items-center ${
isChecked ? 'file-node-selected' : 'fileNodeNormal'
}`;
/**
* 在整行点击的处理函数 不是点击 checkbox 或者 radio 或者 expandIcon 的处理函数
* @param isLeaf: 是否是叶子结点
* @param onExpand: 展开行 处理函数
* @param onCheck: 选中状态的 toggle
**/
const getItemAction = (params: {
isLeaf: boolean;
onExpand: (e: React.MouseEvent<Element, MouseEvent>) => void;
onCheck: (e: React.MouseEvent<Element, MouseEvent>) => void;
unCheckable: boolean;
}) => {
const { onExpand, onCheck, isLeaf, unCheckable } = params;
return function (e: React.MouseEvent<Element, MouseEvent>) {
// 如果只能选中叶子结点 那么无论 多选 / 单选 父节点只能展开,叶子结点可以选中
// 反之 父节点子节点 都是选中 无论多选单选,想要展开就点击 expand icon
if (onlySelectLeaf) {
if (!isLeaf) {
onExpand(e);
} else {
if (!unCheckable) {
onCheck(e);
}
}
} else {
if (!unCheckable) {
onCheck(e);
}
}
};
};
const labelRenderer = (
renderProps: RenderFullLabelProps & {
data: FileNode;
},
) => {
const {
data,
className,
level,
onCheck,
onExpand,
onClick,
checkStatus,
style,
expandIcon,
} = renderProps;
const { indentSize, disableSelect } = renderOption;
const {
label,
key,
type,
isLoading,
loadingInfo,
render,
readonly,
unCheckable = false,
} = data;
// 单选选中选项的 key
const singleSelectCheckStatus = singleSelectedKey === key;
const rowCheckStatus = multiple
? checkStatus
: {
checked: singleSelectCheckStatus,
};
// 只要 data isLeaf 不是空 永远先看 data.isLeaf
const isLeaf = data?.isLeaf ?? !(data.children && data.children.length);
const checkItem = multiple
? (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onCheck(e);
}
: (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onClick(e);
setSingleSelectKey(key);
};
const indent = indentSize * level;
const rowStyle = {
...style,
paddingLeft: indent,
};
const renderExpandIcon = getExpandIcon(
expandIcon as React.ReactElement,
renderOption.expandIcon,
);
return (
<li
style={{
...rowStyle,
}}
className={`${className} ${
checkStatus.checked ? 'semi-tree-option-selected' : ''
} ${disableSelect ? 'semi-tree-option-disable' : ''} ${
readonly ? 'semi-tree-option-readonly' : ''
}`}
role="treeitem"
onClick={
readonly
? undefined
: getItemAction({
isLeaf,
onExpand,
onCheck: checkItem,
unCheckable,
})
}
>
<div className={getFileContentClassName(rowCheckStatus.checked)}>
{render ? (
render()
) : (
<>
<ActionComponent
checkStatus={rowCheckStatus}
isLeaf={isLeaf}
renderExpandIcon={renderExpandIcon}
onlySelectLeaf={onlySelectLeaf}
multiple={multiple}
disableSelect={!!disableSelect}
unCheckable={unCheckable}
/>
<LabelContent
iconUrl={data.icon}
label={label}
type={type}
isLoading={isLoading}
loadingInfo={loadingInfo}
/>
</>
)}
</div>
</li>
);
};
return labelRenderer;
}

View File

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

View File

@@ -0,0 +1,99 @@
/*
* 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 { isObject } from 'lodash-es';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
export enum TreeNodeType {
FILE_TEXT = 2,
FILE_TABLE = 3,
FOLDER = 1,
}
export interface PickerRef {
search?: (searchText: string) => void;
}
/**
* 文件选择树节点
*/
export interface FileNode extends TreeNodeData {
/** 独一无二的 key 标识 可以用 文件 id */
key: string;
value?: string;
label?: React.ReactNode;
type?: TreeNodeType;
// icon 的 URL
icon?: string;
children?: FileNode[];
/** 标识当前节点是不是叶子结点 loadData 时必备 */
isLeaf?: boolean;
/** 该节点是否可以选中 */
selectable?: boolean;
/** 节点的 loading 状态,开启后 loading 默认替换 icon展示 loadingInfo
* 注意这个和 semi 本身带的 loading 不一样semi 的 loading 指的是 展开的 loading 状态 */
isLoading?: boolean;
/** 节点 loading 的提示,默认是 `获取中` */
loadingInfo?: string;
/** 具体的文档类型 比如 doc docx txt 等 */
file_type?: string;
/** 三方文档链接 */
file_url?: string;
/** 【飞书场景】wiki 空间id,*/
space_id?: string;
/** 【飞书场景】wiki 叶子id,*/
obj_token?: string;
/** 自定义渲染 Item */
render?: () => ReactNode;
/** 只读,不可交互 */
readonly?: boolean;
/** 节点是否不可选择,默认为 false */
unCheckable?: boolean;
}
export type FileId = string;
/**
* 文件选择树 节点选择状态
*/
export interface FileSelectCheckStatus {
checked: boolean;
halfChecked: boolean;
}
// 三部分 当前选中的 新增选中的 较上次不选中的 较上次不变的
export type TransSelectedFilesMiddleware = (
fileNodes: FileNode[],
) => FileNode[];
export type CalcCurrentSelectFilesMiddleware = (
fileNodes: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes?: FileNode[],
) => FileNode[];
/** 类型断言 节点是不是 fileNode */
export function isFileNode(fileNode: unknown): fileNode is FileNode {
return !!fileNode && isObject(fileNode) && !!(fileNode as FileNode).key;
}
/** 类型断言 数组是不是 fileNode 数组 */
export function isFileNodeArray(fileNodes: unknown[]): fileNodes is FileNode[] {
return fileNodes.every(fileNode => isFileNode(fileNode));
}

View File

@@ -0,0 +1,120 @@
/*
* 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 { FileNode, TransSelectedFilesMiddleware } from './types';
export const getLeafFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files || files.length === 0) {
return [];
}
const leafFiles: FileNode[] = [];
const helpQueue = Array.from(files);
while (helpQueue.length > 0) {
const curFile = helpQueue.shift();
if (
curFile?.isLeaf ||
!curFile?.children ||
curFile.children.length === 0
) {
curFile && leafFiles.push(curFile);
} else {
helpQueue.push(...curFile.children);
}
}
return leafFiles;
};
function levelMapTreeNodes<
T extends {
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): T[] {
const allNode: T[] = [];
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNode.push(curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNode;
}
export function levelMapTreeNodesToMap<
T extends {
key: string;
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): Map<string, T> {
const allNodeMap: Map<string, T> = new Map();
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNodeMap.set(curFile.key, curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNodeMap;
}
export const appendAllAddFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes: FileNode[] = [],
// eslint-disable-next-line max-params
) => {
if (!files || files.length === 0) {
return [];
}
if (!addNodes) {
return files;
}
const allRemoveFiles: FileNode[] = levelMapTreeNodes<FileNode>(
removeNodes ?? [],
);
const removeKeys = new Set(allRemoveFiles.map(file => file.key));
const allAddFiles: FileNode[] = levelMapTreeNodes<FileNode>(addNodes ?? []);
return [...allAddFiles, ...retainNodes].filter(
file => !removeKeys.has(file.key),
);
};
export const distinctFileNodes: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files) {
return [];
}
const distinctFiles: FileNode[] = [];
const distinctFileKey: Set<string> = new Set();
for (const file of files) {
if (distinctFileKey.has(file.key)) {
continue;
}
distinctFileKey.add(file.key);
distinctFiles.push(file);
}
return distinctFiles;
};

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { PreviewMd } from './doc-preview/preview-md';
export { PreviewTxt } from './doc-preview/preview-txt';
export { usePreviewPdf } from './doc-preview/use-preview-pdf';
export { default as SegmentMenu } from './segment-menu';
export { DocumentEditor } from './text-knowledge-editor/features/editor';
export { DocumentPreview } from './text-knowledge-editor/features/preview';
export { LevelTextKnowledgeEditor } from './text-knowledge-editor/scenes/level';
export { BaseTextKnowledgeEditor } from './text-knowledge-editor/scenes/base';

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

View File

@@ -0,0 +1,69 @@
/*
* 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 DOMPurify from 'dompurify';
import classNames from 'classnames';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { getRenderHtmlContent } from '@/text-knowledge-editor/services/use-case/get-render-editor-content';
import { getEditorWordsCls } from '@/text-knowledge-editor/services/inner/get-editor-words-cls';
import { getEditorTableClassname } from '@/text-knowledge-editor/services/inner/get-editor-table-cls';
import { getEditorImgClassname } from '@/text-knowledge-editor/services/inner/get-editor-img-cls';
export const DocumentChunkPreview = ({
chunk,
locateId,
}: {
chunk: Chunk;
locateId: string;
}) => (
<div
id={locateId}
className={classNames(
// 布局
'relative',
// 间距
'mb-2 p-2',
// 文字样式
'text-sm leading-5',
// 颜色
'coz-fg-primary hover:coz-mg-hglt-secondary-hovered coz-mg-secondary',
// 边框
'border border-solid coz-stroke-primary rounded-lg',
// 表格样式
getEditorTableClassname(),
// 图片样式
getEditorImgClassname(),
// 换行
getEditorWordsCls(),
)}
>
<p
// 已使用 DOMPurify 过滤 xss
// eslint-disable-next-line risxss/catch-potential-xss-react
dangerouslySetInnerHTML={{
__html:
DOMPurify.sanitize(getRenderHtmlContent(chunk.content ?? ''), {
/**
* 1. 防止CSS注入攻击
* 2. 防止用户误写入style标签导致全局样式被修改页面展示异常
*/
FORBID_TAGS: ['style'],
}) ?? '',
}}
/>
</div>
);

View File

@@ -0,0 +1,67 @@
/*
* 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 DOMPurify from 'dompurify';
import cls from 'classnames';
export const ImageChunkPreview = ({
base64,
htmlText,
link,
caption,
locateId,
selected,
}: {
base64?: string;
htmlText?: string;
link?: string;
caption?: string;
locateId: string;
selected?: boolean;
}) => (
<div
id={locateId}
className={cls(
'flex items-center flex-col gap-2',
'w-full p-2 coz-mg-secondary',
'border border-solid coz-stroke-primary rounded-[8px]',
selected && '!coz-mg-hglt',
)}
>
{base64 ? (
<img
src={`data:image/jpeg;base64, ${base64}`}
className="w-full h-full"
/>
) : null}
{htmlText ? (
<div
className="w-full h-full overflow-auto [&>*]:w-full [&>*]:h-full"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlText) }}
/>
) : null}
{link ? (
<div className="coz-fg-primary text-[14px] leading-[20px] font-[400] break-all">
{link}
</div>
) : null}
{caption ? (
<div className="coz-fg-primary text-[14px] leading-[20px] font-[400] break-all">
{caption}
</div>
) : null}
</div>
);

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import cls from 'classnames';
export const TitleChunkPreview = ({
title,
id,
}: {
title: string;
id: string;
}) => (
<div
id={id}
className={cls('w-full text-[14px] font-[500] leading-[20px] coz-fg-plus')}
>
{title}
</div>
);

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 { useEffect } from 'react';
import mitt from 'mitt';
import type { Emitter, Handler, EventType } from 'mitt';
import { type Chunk } from '../types/chunk';
// 定义事件名称字面量类型
export type EventTypeName =
| 'previewContextMenuItemAction'
| 'hoverEditBarAction';
/**
* 事件类型定义
*/
export interface EventTypes extends Record<EventType, unknown> {
// 右键菜单相关事件
previewContextMenuItemAction: {
type: 'add-after' | 'add-before' | 'delete' | 'edit';
targetChunk: Chunk;
newChunk?: Chunk;
chunks?: Chunk[];
};
// 悬浮编辑栏相关事件
hoverEditBarAction: {
type: 'add-after' | 'add-before' | 'delete' | 'edit';
targetChunk: Chunk;
newChunk?: Chunk;
chunks?: Chunk[];
};
}
/**
* 事件处理函数类型
*/
export type EventHandler<T extends EventTypeName> = Handler<EventTypes[T]>;
/**
* 创建事件总线实例
*/
export const createEventBus = (): Emitter<EventTypes> => mitt<EventTypes>();
/**
* 全局事件总线实例
*/
export const eventBus = createEventBus();
/**
* 事件总线钩子
* 用于在组件中使用事件总线
*/
export const useEventBus = () => eventBus;
/**
* 监听事件钩子
* 用于在组件中监听事件
* @param eventName 事件名称
* @param handler 事件处理函数
* @param deps 依赖数组,当依赖变化时重新绑定事件
*/
export const useEventListener = <T extends EventTypeName>(
eventName: T,
handler: EventHandler<T>,
deps: React.DependencyList = [],
) => {
useEffect(() => {
// 绑定事件
eventBus.on(eventName, handler as Handler<unknown>);
// 组件卸载时解绑事件
return () => {
eventBus.off(eventName, handler as Handler<unknown>);
};
}, deps);
};

View File

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

View File

@@ -0,0 +1,33 @@
/*
* 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 React from 'react';
import { type Editor } from '@tiptap/react';
export interface EditorActionProps {
editor: Editor | null;
disabled?: boolean;
onlyIcon?: boolean;
showTooltip?: boolean;
}
export interface EditorActionModule {
Component: React.ComponentType<EditorActionProps>;
showTooltip?: boolean;
disabled?: boolean;
onlyIcon?: boolean;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 { FeatureRegistry } from '@coze-data/feature-register';
import { type EditorActionModule } from './module';
export type EditorActionFeatureType = 'upload-image';
export type EditorActionRegistry = FeatureRegistry<
EditorActionFeatureType,
EditorActionModule
>;
export const createEditorActionFeatureRegistry = (
name: string,
): EditorActionRegistry =>
new FeatureRegistry<EditorActionFeatureType, EditorActionModule>({
name,
});

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { type Editor } from '@tiptap/react';
import { I18n } from '@coze-arch/i18n';
import { Tooltip, type customRequestArgs } from '@coze-arch/coze-design';
import { type EditorActionProps } from '../module';
import { CustomUpload, handleCustomUploadRequest } from './custom-upload';
export interface BaseUploadImageProps extends EditorActionProps {
editor: Editor | null;
renderUI: (props: {
disabled?: boolean;
showTooltip?: boolean;
}) => React.ReactNode;
}
export const BaseUploadImage = ({
editor,
disabled,
showTooltip,
renderUI,
}: BaseUploadImageProps) => {
// 处理图片上传
const handleImageUpload = (object: customRequestArgs) => {
if (!editor) {
return;
}
const { fileInstance } = object;
if (!fileInstance) {
return;
}
return handleCustomUploadRequest({
object,
options: {
onFinish: (result: { url?: string; tosKey?: string }) => {
if (result.url && editor) {
// 插入图片到编辑器
editor.chain().focus().setImage({ src: result.url }).run();
}
},
},
});
};
const TooltipWrapper = showTooltip ? Tooltip : React.Fragment;
return (
<CustomUpload customRequest={handleImageUpload}>
<TooltipWrapper
content={I18n.t('knowledge_insert_img_002')}
clickToHide
autoAdjustOverflow
>
{renderUI({ disabled, showTooltip })}
</TooltipWrapper>
</CustomUpload>
);
};

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import { IconCozImage } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageButton = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<Button
disabled={disabled}
color="primary"
className="coz-fg-primary leading-none"
icon={<IconCozImage className="text-[14px]" />}
>
{I18n.t('knowledge_insert_img_002')}
</Button>
)}
/>
);

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 PropsWithChildren } from 'react';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS as ReportEventNames } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { Upload, Toast, type customRequestArgs } from '@coze-arch/coze-design';
import { type UploadProps } from '@coze-arch/bot-semi/Upload';
import { CustomError } from '@coze-arch/bot-error';
import { FileBizType } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
import {
getBase64,
getFileExtension,
isValidSize,
} from '@/text-knowledge-editor/utils/upload';
interface CustomUploadProps {
customRequest: UploadProps['customRequest'];
}
export const CustomUpload: React.FC<PropsWithChildren<CustomUploadProps>> = ({
customRequest,
children,
}) => (
<Upload
accept="image/*"
maxSize={20480}
fileList={[]}
customRequest={customRequest}
onChange={fileItem => {
const { currentFile } = fileItem;
if (currentFile) {
const isValid = isValidSize(currentFile?.fileInstance?.size || 0);
if (!isValid) {
Toast.error(I18n.t('knowledge_insert_img_013'));
}
}
}}
>
{children}
</Upload>
);
export interface CustomRequestParams {
object: customRequestArgs;
options: {
onFinish?: (result: { url?: string; tosKey?: string }) => void;
onFinally?: () => void;
onBeforeUpload?: () => void;
};
}
export const handleCustomUploadRequest = async ({
object,
options,
}: CustomRequestParams) => {
const { onSuccess, onProgress, file } = object;
const { onFinish, onFinally, onBeforeUpload } = options;
if (typeof file === 'string') {
return;
}
try {
// 业务逻辑
onBeforeUpload?.();
const { name, fileInstance } = file;
if (fileInstance) {
const extension = getFileExtension(name);
const base64 = await getBase64(fileInstance);
const result = await DeveloperApi.UploadFile(
{
file_head: {
file_type: extension,
biz_type: FileBizType.BIZ_BOT_DATASET,
},
data: base64,
},
{
onUploadProgress: e => {
onProgress({
total: e.total ?? fileInstance.size,
loaded: e.loaded,
});
},
},
);
onSuccess(result.data);
if (result.data) {
onFinish?.({
url: result.data.upload_url,
tosKey: result.data.upload_uri,
});
}
} else {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: ReportEventNames.KnowledgeUploadFile,
error: new CustomError(
ReportEventNames.KnowledgeUploadFile,
`${ReportEventNames.KnowledgeUploadFile}: Failed to upload image`,
),
});
}
} catch (error) {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: ReportEventNames.KnowledgeUploadFile,
error: error as Error,
});
} finally {
onFinally?.();
}
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IconCozImage } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageIcon = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<IconButton
disabled={disabled}
size="small"
color="secondary"
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
icon={<IconCozImage className="text-[14px]" />}
/>
)}
/>
);

View File

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

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageMenu = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<Menu.Item
disabled={disabled}
icon={
<IconCozImage
className={classNames('w-3.5 h-3.5', {
'opacity-30': disabled,
})}
/>
}
className={classNames('h-8 p-2 text-xs rounded-lg', {
'cursor-not-allowed': disabled,
})}
>
{I18n.t('knowledge_insert_img_002')}
</Menu.Item>
)}
/>
);

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { type Editor } from '@tiptap/react';
import { Menu } from '@coze-arch/coze-design';
import { type EditorActionRegistry } from '@/text-knowledge-editor/features/editor-actions/registry';
interface EditorContextMenuProps {
x: number;
y: number;
editor: Editor | null;
readonly?: boolean;
contextMenuRef: React.RefObject<HTMLDivElement>;
editorActionRegistry: EditorActionRegistry;
}
export const EditorContextMenu: React.FC<EditorContextMenuProps> = props => {
const { editorActionRegistry, readonly, contextMenuRef, x, y, editor } =
props;
if (readonly) {
return null;
}
return (
<div
ref={contextMenuRef}
className="absolute bg-white shadow-lg rounded-md py-1 z-50"
style={{
top: `${y}px`,
left: `${x}px`,
}}
>
<Menu
visible
clickToHide
keepDOM
position="bottomLeft"
spacing={-4}
trigger="custom"
getPopupContainer={() => contextMenuRef.current ?? document.body}
className={classNames('coz-shadow-large')}
render={
<Menu.SubMenu className={classNames('p-1')} mode="menu">
{editorActionRegistry.entries().map(([key, { Component }]) => (
<Component key={key} editor={editor} />
))}
</Menu.SubMenu>
}
/>
</div>
);
};

View File

@@ -0,0 +1,35 @@
/*
* 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 Editor } from '@tiptap/react';
import { type EditorActionRegistry } from '../editor-actions/registry';
export interface EditorToolbarProps {
editor: Editor | null;
actionRegistry: EditorActionRegistry;
}
export const EditorToolbar = ({
editor,
actionRegistry,
}: EditorToolbarProps) => (
<div className="h-[32px] box-content px-2 pt-2">
{actionRegistry.entries().map(([key, { Component }]) => (
<Component key={key} editor={editor} />
))}
</div>
);

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 React, { useRef } from 'react';
import classNames from 'classnames';
import { EditorContent, type Editor } from '@tiptap/react';
import { getEditorContent } from '@/text-knowledge-editor/services/use-case/get-editor-content';
import { getEditorWordsCls } from '@/text-knowledge-editor/services/inner/get-editor-words-cls';
import { getEditorTableClassname } from '@/text-knowledge-editor/services/inner/get-editor-table-cls';
import { getEditorImgClassname } from '@/text-knowledge-editor/services/inner/get-editor-img-cls';
import { useOutEditorMode } from '@/text-knowledge-editor/hooks/inner/use-out-editor-mode';
import { useControlContextMenu } from '@/text-knowledge-editor/hooks/inner/use-control-context-menu';
import { EditorContextMenu } from '../editor-context-menu';
import { type EditorActionRegistry } from '../editor-actions/registry';
interface DocumentEditorProps {
editor: Editor | null;
placeholder?: string;
editorContextMenuItemsRegistry?: EditorActionRegistry;
editorBottomSlot?: React.ReactNode;
onBlur?: (newContent: string) => void;
}
export const DocumentEditor: React.FC<DocumentEditorProps> = props => {
const {
editor,
placeholder,
editorContextMenuItemsRegistry,
editorBottomSlot,
onBlur,
} = props;
const editorRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
/**
* 当右键点击编辑器时,显示上下文菜单
*/
const { contextMenuPosition, openContextMenu } = useControlContextMenu({
contextMenuRef,
});
/**
* 当点击编辑器外部时
*/
useOutEditorMode({
editorRef,
exclude: [contextMenuRef],
onExitEditMode: () => {
const newContent = getEditorContent(editor);
onBlur?.(newContent);
},
});
if (!editor) {
return null;
}
return (
<div className="relative">
<div
ref={editorRef}
className={classNames(
// 布局
'relative',
// 间距
'mb-2 p-2',
// 文字样式
'text-sm leading-5',
// 颜色
'coz-fg-primary coz-bg-max',
// 边框
'border border-solid coz-stroke-hglt rounded-lg',
)}
onContextMenu={openContextMenu}
>
<div
className={classNames(
// 表格样式
getEditorTableClassname(),
// 图片样式
getEditorImgClassname(),
// 换行
getEditorWordsCls(),
)}
>
<EditorContent editor={editor} placeholder={placeholder} />
{editorBottomSlot}
</div>
</div>
{/* 右键菜单 */}
{contextMenuPosition && editorContextMenuItemsRegistry ? (
<EditorContextMenu
x={contextMenuPosition.x}
y={contextMenuPosition.y}
contextMenuRef={contextMenuRef}
editor={editor}
editorActionRegistry={editorContextMenuItemsRegistry}
/>
) : null}
</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 { DocumentEditor } from './editor';

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddBottom } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
export const AddAfterAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks,
disabled,
}) => {
// 在特定分片后添加新分片
const { addEmptyChunkAfter } = useAddEmptyChunkAction({
chunks: chunks || [],
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'add-after',
targetChunk: chunk,
chunks: newChunks,
newChunk,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_optimize_016')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemAddBottomIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozDocumentAddBottom className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => addEmptyChunkAfter(chunk)}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddTop } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* 在特定分片前添加新分片的操作组件
*/
export const AddBeforeAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks = [],
disabled,
}) => {
// 在特定分片前添加新分片
const { addEmptyChunkBefore } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'add-before',
targetChunk: chunk,
chunks: newChunks,
newChunk,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_optimize_017')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemAddTopIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozDocumentAddTop className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => addEmptyChunkBefore(chunk)}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useDeleteAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* 删除特定分片的操作组件
*
* 内部实现了删除特定分片的逻辑
* 如果传入了 onDelete 回调,则会在点击时调用
* 如果提供了 chunks、onChunksChange则会在内部处理删除逻辑
* 无需依赖外部的 usePreviewContextMenu
*/
export const DeleteAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks = [],
disabled,
}) => {
// 删除特定分片
const { deleteChunk } = useDeleteAction({
chunks,
onChunksChange: ({ chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'delete',
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_level_028')}
clickToHide
autoAdjustOverflow
>
<IconButton
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozTrashCan className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => deleteChunk(chunk)}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* 编辑操作组件
*
* 内部实现了激活特定分片的编辑模式的逻辑
* 如果传入了 onEdit 回调,则会在点击时调用
*/
export const EditAction: React.FC<HoverEditBarActionProps> = ({
chunk,
disabled,
}) => (
<Tooltip
content={I18n.t('datasets_segment_edit')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemEditIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozEdit className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => {
eventBus.emit('hoverEditBarAction', {
type: 'edit',
targetChunk: chunk,
});
}}
/>
</Tooltip>
);

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EditAction } from './edit-action';
export { DeleteAction } from './delete-action';
export { AddBeforeAction } from './add-before-action';
export { AddAfterAction } from './add-after-action';
export {
createHoverEditBarActionFeatureRegistry,
type HoverEditBarActionFeatureType,
type HoverEditBarActionRegistry,
} from './registry';
export type {
HoverEditBarActionModule,
HoverEditBarActionProps,
} from './module';

View File

@@ -0,0 +1,30 @@
/*
* 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 React from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export interface HoverEditBarActionProps {
chunk: Chunk;
chunks?: Chunk[];
disabled?: boolean;
onChunksChange?: (chunks: Chunk[]) => void;
}
export interface HoverEditBarActionModule {
Component: React.ComponentType<HoverEditBarActionProps>;
}

View File

@@ -0,0 +1,37 @@
/*
* 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 { FeatureRegistry } from '@coze-data/feature-register';
import { type HoverEditBarActionModule } from './module';
export type HoverEditBarActionFeatureType =
| 'edit'
| 'delete'
| 'add-before'
| 'add-after';
export type HoverEditBarActionRegistry = FeatureRegistry<
HoverEditBarActionFeatureType,
HoverEditBarActionModule
>;
export const createHoverEditBarActionFeatureRegistry = (
name: string,
): HoverEditBarActionRegistry =>
new FeatureRegistry<HoverEditBarActionFeatureType, HoverEditBarActionModule>({
name,
});

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { IconButton, Space, Tooltip } from '@coze-arch/coze-design';
import { SliceStatus } from '@coze-arch/bot-api/knowledge';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { type HoverEditBarActionRegistry } from '@/text-knowledge-editor/features/hover-edit-bar-actions/registry';
export interface HoverEditBarProps {
chunk: Chunk;
chunks: Chunk[];
disabled?: boolean;
hoverEditBarActionsRegistry: HoverEditBarActionRegistry;
onChunksChange?: (chunks: Chunk[]) => void;
}
export const HoverEditBar: React.FC<HoverEditBarProps> = ({
chunk,
chunks,
disabled,
hoverEditBarActionsRegistry,
onChunksChange,
}) => {
const isAudiFailed = chunk.status === SliceStatus.AuditFailed;
const iconButtonCommonClasses = 'coz-fg-secondary leading-none !w-6 !h-6';
if (!hoverEditBarActionsRegistry) {
return null;
}
return (
<div className="absolute top-[2px] right-[2px] flex z-10">
{!disabled ? (
<div
className={classNames(
'p-1 coz-bg-plus rounded-lg',
'coz-shadow-default',
)}
>
<Space spacing={3}>
{hoverEditBarActionsRegistry
.entries()
.map(([key, { Component }]) => (
<Component
key={key}
chunk={chunk}
chunks={chunks}
onChunksChange={onChunksChange}
/>
))}
</Space>
</div>
) : null}
{isAudiFailed ? (
<div
className={classNames(
'p-1 coz-bg-plus rounded-lg',
'coz-shadow-default',
'ml-1',
)}
>
<Tooltip
content={I18n.t('community_This_is_a_toast_Machine_review_failed')}
clickToHide
autoAdjustOverflow
>
<IconButton
icon={
<IconCozInfoCircle className="text-[14px] coz-fg-hglt-red" />
}
size="small"
color="secondary"
className={iconButtonCommonClasses}
/>
</Tooltip>
</div>
) : null}
</div>
);
};

View File

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

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 React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddBottom } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* 在特定分片后添加新分片的菜单项组件
*/
export const AddAfterAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// 在特定分片后添加新分片
const { addEmptyChunkAfter } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
// 发出在特定分片后添加新分片的事件
eventBus.emit('previewContextMenuItemAction', {
type: 'add-after',
newChunk,
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozDocumentAddBottom className={getIconStyles(!!disabled)} />}
onClick={() => addEmptyChunkAfter(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('knowledge_optimize_016')}
</Menu.Item>
);
};

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddTop } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* 在特定分片前添加新分片的菜单项组件
*/
export const AddBeforeAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// 在特定分片前添加新分片
const { addEmptyChunkBefore } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('previewContextMenuItemAction', {
type: 'add-before',
targetChunk: chunk,
newChunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozDocumentAddTop className={getIconStyles(!!disabled)} />}
onClick={() => addEmptyChunkBefore(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('knowledge_optimize_017')}
</Menu.Item>
);
};

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useDeleteAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* 删除特定分片的菜单项组件
*/
export const DeleteAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// 删除特定分片
const { deleteChunk } = useDeleteAction({
chunks,
onChunksChange: ({ chunks: newChunks }) => {
eventBus.emit('previewContextMenuItemAction', {
type: 'delete',
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozTrashCan className={getIconStyles(!!disabled)} />}
onClick={() => deleteChunk(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('Delete')}
</Menu.Item>
);
};

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* 编辑操作菜单项组件
*
* 内部实现了激活特定分片的编辑模式的逻辑
* 如果传入了 onEdit 回调,则会在点击时调用
*/
export const EditAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozEdit className={getIconStyles(!!disabled)} />}
onClick={() => {
eventBus.emit('previewContextMenuItemAction', {
type: 'edit',
targetChunk: chunk,
});
}}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('Edit')}
</Menu.Item>
);
};

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EditAction } from './edit-action';
export { DeleteAction } from './delete-action';
export { AddBeforeAction } from './add-before-action';
export { AddAfterAction } from './add-after-action';
export {
createPreviewContextMenuItemFeatureRegistry,
type PreviewContextMenuItemFeatureType,
type PreviewContextMenuItemRegistry,
} from './registry';
export type {
PreviewContextMenuItemModule,
PreviewContextMenuItemProps,
} from './module';

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 React from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export interface PreviewContextMenuItemProps {
chunk: Chunk;
chunks?: Chunk[];
disabled?: boolean;
}
export interface PreviewContextMenuItemModule {
Component: React.ComponentType<PreviewContextMenuItemProps>;
}

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 { FeatureRegistry } from '@coze-data/feature-register';
import { type PreviewContextMenuItemModule } from './module';
export type PreviewContextMenuItemFeatureType =
| 'edit'
| 'delete'
| 'add-before'
| 'add-after';
export type PreviewContextMenuItemRegistry = FeatureRegistry<
PreviewContextMenuItemFeatureType,
PreviewContextMenuItemModule
>;
export const createPreviewContextMenuItemFeatureRegistry = (
name: string,
): PreviewContextMenuItemRegistry =>
new FeatureRegistry<
PreviewContextMenuItemFeatureType,
PreviewContextMenuItemModule
>({
name,
});

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { Menu } from '@coze-arch/coze-design';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { type PreviewContextMenuItemRegistry } from '@/text-knowledge-editor/features/preview-context-menu-items/registry';
interface PreviewContextMenuProps {
x: number;
y: number;
chunk: Chunk;
chunks: Chunk[];
readonly?: boolean;
contextMenuRef: React.RefObject<HTMLDivElement>;
previewContextMenuItemsRegistry: PreviewContextMenuItemRegistry;
}
const PreviewContextMenu: React.FC<PreviewContextMenuProps> = props => {
const {
previewContextMenuItemsRegistry,
chunk,
chunks,
readonly,
contextMenuRef,
x,
y,
} = props;
if (readonly) {
return null;
}
return (
<div
ref={contextMenuRef}
className="absolute bg-white shadow-lg rounded-md py-1 z-50"
style={{
top: `${y}px`,
left: `${x}px`,
}}
>
<Menu
visible
position="bottomLeft"
spacing={-4}
trigger="custom"
getPopupContainer={() => contextMenuRef.current ?? document.body}
className={classNames('rounded-lg')}
render={
<Menu.SubMenu className={classNames('w-40 p-1')} mode="menu">
{previewContextMenuItemsRegistry
.entries()
.map(([key, { Component }]) => (
<Component key={key} chunk={chunk} chunks={chunks} />
))}
</Menu.SubMenu>
}
/>
</div>
);
};
export default PreviewContextMenu;

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 { DocumentPreview } from './preview';

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useRef } from 'react';
import classNames from 'classnames';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { useHoverEffect } from '@/text-knowledge-editor/hooks/inner/use-hover-effect';
import { useControlContextMenu } from '@/text-knowledge-editor/hooks/inner/use-control-context-menu';
import { DocumentChunkPreview } from '@/text-knowledge-editor/components/preview-chunk/document';
import { type PreviewContextMenuItemRegistry } from '../preview-context-menu-items/registry';
import PreviewContextMenu from '../preview-context-menu';
import { type HoverEditBarActionRegistry } from '../hover-edit-bar-actions/registry';
import { HoverEditBar } from '../hover-edit-bar';
interface DocumentPreviewProps {
chunk: Chunk;
chunks: Chunk[];
readonly?: boolean;
locateId?: string;
hoverEditBarActionsRegistry: HoverEditBarActionRegistry;
previewContextMenuItemsRegistry: PreviewContextMenuItemRegistry;
onActivateEditMode?: (chunk: Chunk) => void;
}
const DocumentPreviewComponent: React.FC<DocumentPreviewProps> = props => {
const {
chunk,
chunks,
readonly = false,
locateId,
onActivateEditMode,
hoverEditBarActionsRegistry,
previewContextMenuItemsRegistry,
} = props;
const contextMenuRef = useRef<HTMLDivElement>(null);
const { hoveredChunk, handleMouseEnter, handleMouseLeave } = useHoverEffect();
const { contextMenuPosition, openContextMenu } = useControlContextMenu({
contextMenuRef,
});
return (
<div className="relative">
<div
className={classNames(
// 布局
'relative overflow-hidden',
)}
onContextMenu={readonly ? undefined : e => openContextMenu(e)}
onMouseEnter={
readonly
? undefined
: () => handleMouseEnter(chunk.text_knowledge_editor_chunk_uuid)
}
onMouseLeave={readonly ? undefined : handleMouseLeave}
onDoubleClick={readonly ? undefined : () => onActivateEditMode?.(chunk)}
>
{/* 悬停时显示的操作栏 */}
{hoveredChunk === chunk.text_knowledge_editor_chunk_uuid &&
!readonly ? (
<HoverEditBar
chunk={chunk}
chunks={chunks}
hoverEditBarActionsRegistry={hoverEditBarActionsRegistry}
/>
) : null}
<DocumentChunkPreview chunk={chunk} locateId={locateId || ''} />
</div>
{/* 右键菜单 */}
{contextMenuPosition ? (
<PreviewContextMenu
previewContextMenuItemsRegistry={previewContextMenuItemsRegistry}
x={contextMenuPosition.x}
y={contextMenuPosition.y}
chunk={chunk}
chunks={chunks}
readonly={readonly}
contextMenuRef={contextMenuRef}
/>
) : null}
</div>
);
};
// 使用React.memo包装组件避免不必要的重新渲染
export const DocumentPreview = React.memo(
DocumentPreviewComponent,
(prevProps, nextProps) => {
// 如果分片内容变化,需要重新渲染
if (prevProps.chunk.content !== nextProps.chunk.content) {
return false;
}
// 其他情况下不需要重新渲染
return true;
},
);

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
interface UseControlContextMenuProps {
contextMenuRef: React.RefObject<HTMLDivElement>;
}
export const useControlContextMenu = ({
contextMenuRef,
}: UseControlContextMenuProps) => {
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
// 处理右键菜单
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
// 计算相对于事件目标元素的位置
const rect = e.currentTarget.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;
setContextMenuPosition({
x: relativeX,
y: relativeY,
});
};
// 关闭右键菜单
const closeContextMenu = () => {
setContextMenuPosition(null);
};
// 处理点击文档其他位置
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// 如果点击的是右键菜单外部,则关闭菜单
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuPosition,
openContextMenu,
closeContextMenu,
};
};

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
interface UseControlEditorContextMenuProps {
contextMenuRef: React.RefObject<HTMLDivElement>;
}
export const useControlEditorContextMenu = ({
contextMenuRef,
}: UseControlEditorContextMenuProps) => {
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
// 处理右键菜单
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setContextMenuPosition({
x: e.clientX,
y: e.clientY,
});
};
// 关闭右键菜单
const closeContextMenu = () => {
setContextMenuPosition(null);
};
// 处理点击文档其他位置
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// 如果点击的是右键菜单外部,则关闭菜单
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuPosition,
openContextMenu,
closeContextMenu,
};
};

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export const useControlPreviewContextMenu = () => {
const [contextMenuInfo, setContextMenuInfo] = useState<{
x: number;
y: number;
chunk: Chunk;
} | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
// 处理右键点击事件
const openContextMenu = (e: React.MouseEvent, chunk: Chunk) => {
e.preventDefault();
setContextMenuInfo({
x: e.clientX,
y: e.clientY,
chunk,
});
};
// 关闭右键菜单
const closeContextMenu = () => {
setContextMenuInfo(null);
};
// 点击文档其他位置关闭右键菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuInfo,
contextMenuRef,
openContextMenu,
closeContextMenu,
};
};

View File

@@ -0,0 +1,65 @@
/*
* 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 { useRequest } from 'ahooks';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
import { createRemoteChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
export interface UseCreateChunkProps {
documentId: string;
}
export const useCreateChunk = ({ documentId }: UseCreateChunkProps) => {
const { runAsync } = useRequest(
async (props: { content: string; sequence: string }) => {
const { content, sequence } = props;
if (!documentId) {
throw new CustomError('normal_error', 'missing doc_id');
}
const data = await KnowledgeApi.CreateSlice({
document_id: documentId,
raw_text: content,
sequence,
});
const chunk = createRemoteChunk({
slice_id: data?.slice_id ?? '',
sequence,
content,
});
return chunk;
},
{
manual: true,
onError: error => {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: REPORT_EVENTS.KnowledgeCreateSlice,
error,
});
},
},
);
return {
createChunk: runAsync,
};
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRequest } from 'ahooks';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
export const useDeleteChunk = () => {
const { runAsync: deleteSlice } = useRequest(
async (sliceId: string) => {
if (!sliceId) {
throw new CustomError('normal_error', 'missing slice_id');
}
await KnowledgeApi.DeleteSlice({
slice_ids: [sliceId],
});
},
{
manual: true,
},
);
return {
deleteSlice,
};
};

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
export const useHoverEffect = () => {
const [hoveredChunk, setHoveredChunk] = useState<string | null>(null);
// 处理鼠标悬停事件
const handleMouseEnter = (chunkId: string) => {
setHoveredChunk(chunkId);
};
// 处理鼠标离开事件
const handleMouseLeave = () => {
setHoveredChunk(null);
};
return {
hoveredChunk,
handleMouseEnter,
handleMouseLeave,
};
};

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
export interface UseOutEditorModeProps {
editorRef: React.RefObject<HTMLDivElement>;
exclude?: React.RefObject<HTMLDivElement>[];
onExitEditMode?: () => void;
}
export const useOutEditorMode = ({
editorRef,
exclude,
onExitEditMode,
}: UseOutEditorModeProps) => {
// 处理点击文档其他位置
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// 如果点击的是编辑器外部,则退出编辑模式
if (
editorRef.current &&
!editorRef.current.contains(event.target as Node) &&
!exclude?.some(ref => ref.current?.contains(event.target as Node))
) {
onExitEditMode?.();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [editorRef, exclude, onExitEditMode]);
};

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 { useRequest } from 'ahooks';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
export const useUpdateChunk = () => {
const { runAsync: updateSlice, loading: updateLoading } = useRequest(
async (sliceId: string, updateContent: string) => {
if (!sliceId) {
throw new CustomError('normal_error', 'missing slice_id');
}
await KnowledgeApi.UpdateSlice({
slice_id: sliceId,
raw_text: updateContent,
});
return updateContent;
},
{
manual: true,
},
);
return {
updateSlice,
updateLoading,
};
};

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 { useAddEmptyChunkAction } from './use-add-empty-chunk-action';
export { useDeleteAction } from './use-delete-action';

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { createLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
interface UseAddEmptyChunkActionProps {
chunks: Chunk[];
onChunksChange?: (params: { newChunk: Chunk; chunks: Chunk[] }) => void;
}
/**
* 在特定分片后添加新分片的 hook
*
* 提供在特定分片后添加新分片的功能
*/
export const useAddEmptyChunkAction = ({
chunks,
onChunksChange,
}: UseAddEmptyChunkActionProps) => {
// 使用ref保存最新的chunks引用
const chunksRef = useRef<Chunk[]>(chunks);
// 每次props.chunks更新时更新ref
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
/**
* 在特定分片后添加新分片
* @returns 包含新分片和更新后的分片列表的结果对象
*/
const handleAddEmptyChunkAfter = (chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
if (index === -1) {
return;
}
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
const newChunk = createLocalChunk({
sequence: String(Number(sequence) + 1),
});
const updatedChunks = [
...currentChunks.slice(0, index + 1),
newChunk,
...currentChunks.slice(index + 1),
];
onChunksChange?.({
newChunk,
chunks: updatedChunks,
});
};
/**
* 在特定分片前添加新分片
*/
const handleAddEmptyChunkBefore = (chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
if (index === -1) {
return;
}
const newChunk = createLocalChunk({
sequence,
});
const updatedChunks = [
...currentChunks.slice(0, index),
newChunk,
...currentChunks.slice(index),
];
onChunksChange?.({
newChunk,
chunks: updatedChunks,
});
};
return {
addEmptyChunkAfter: handleAddEmptyChunkAfter,
addEmptyChunkBefore: handleAddEmptyChunkBefore,
};
};

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { useDeleteChunk } from '@/text-knowledge-editor/hooks/inner/use-delete-chunk';
interface UseDeleteActionProps {
chunks: Chunk[];
onChunksChange?: (params: { chunks: Chunk[]; targetChunk: Chunk }) => void;
}
/**
* 删除分片的 hook
*
* 提供删除特定分片的功能
*/
export const useDeleteAction = ({
chunks,
onChunksChange,
}: UseDeleteActionProps) => {
// 使用ref保存最新的chunks引用
const chunksRef = useRef<Chunk[]>(chunks);
const { deleteSlice } = useDeleteChunk();
// 每次props.chunks更新时更新ref
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
/**
* 删除特定分片
*/
const handleDeleteChunk = useCallback(
(chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const updatedChunks = currentChunks.filter(
c =>
c.text_knowledge_editor_chunk_uuid !==
chunk.text_knowledge_editor_chunk_uuid,
);
if (!chunk.slice_id) {
return;
}
deleteSlice(chunk.slice_id).then(() => {
onChunksChange?.({
chunks: updatedChunks,
targetChunk: chunk,
});
});
},
[onChunksChange, deleteSlice],
);
return {
deleteChunk: handleDeleteChunk,
};
};

View File

@@ -0,0 +1,61 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { updateLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useCreateChunk } from '@/text-knowledge-editor/hooks/inner/use-create-chunk';
export interface UseCreateLocalChunkProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onAddChunk?: (chunk: Chunk) => void;
}
export const useCreateLocalChunk = ({
chunks,
documentId,
onChunksChange,
onAddChunk,
}: UseCreateLocalChunkProps) => {
const { createChunk } = useCreateChunk({
documentId,
});
/**
* 处理本地分片的创建操作
*/
const createLocalChunk = async (chunk: Chunk) => {
if (!chunk.local_slice_id) {
return;
}
const newChunk = await createChunk({
content: chunk.content ?? '',
sequence: chunk.sequence ?? '1',
});
const newChunks = updateLocalChunk({
chunks,
localChunkSliceId: chunk.local_slice_id,
newChunk,
});
onAddChunk?.(newChunk);
onChunksChange?.(newChunks);
};
return {
createLocalChunk,
};
};

View File

@@ -0,0 +1,43 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { deleteLocalChunk as deleteLocalChunkService } from '@/text-knowledge-editor/services/inner/chunk-op.service';
export interface UseDeleteLocalChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
}
export const useDeleteLocalChunk = ({
chunks,
onChunksChange,
}: UseDeleteLocalChunkProps) => {
/**
* 处理本地分片的删除操作
*/
const deleteLocalChunk = (chunk: Chunk) => {
if (!chunk.local_slice_id) {
return;
}
const newChunks = deleteLocalChunkService(chunks, chunk.local_slice_id);
onChunksChange?.(newChunks);
};
return {
deleteLocalChunk,
};
};

View File

@@ -0,0 +1,50 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { deleteRemoteChunk as deleteRemoteChunkService } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useDeleteChunk } from '@/text-knowledge-editor/hooks/inner/use-delete-chunk';
export interface UseDeleteRemoteChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
onDeleteChunk?: (chunk: Chunk) => void;
}
export const useDeleteRemoteChunk = ({
chunks,
onChunksChange,
onDeleteChunk,
}: UseDeleteRemoteChunkProps) => {
const { deleteSlice } = useDeleteChunk();
/**
* 处理远程分片的删除操作
*/
const deleteRemoteChunk = async (chunk: Chunk) => {
if (!chunk.slice_id) {
return;
}
await deleteSlice(chunk.slice_id);
const newChunks = deleteRemoteChunkService(chunks, chunk.slice_id);
onChunksChange?.(newChunks);
onDeleteChunk?.(chunk);
};
return {
deleteRemoteChunk,
};
};

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import StarterKit from '@tiptap/starter-kit';
import { useEditor, type Editor } from '@tiptap/react';
import { type EditorProps } from '@tiptap/pm/view';
import TableRow from '@tiptap/extension-table-row';
import TableHeader from '@tiptap/extension-table-header';
import TableCell from '@tiptap/extension-table-cell';
import Table from '@tiptap/extension-table';
import Image from '@tiptap/extension-image';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { getRenderHtmlContent } from '@/text-knowledge-editor/services/use-case/get-render-editor-content';
import { getEditorContent } from '@/text-knowledge-editor/services/use-case/get-editor-content';
interface UseDocumentEditorProps {
chunk: Chunk | null;
editorProps?: EditorProps;
onChange?: (chunk: Chunk) => void;
}
export const useInitEditor = ({
chunk,
editorProps,
onChange,
}: UseDocumentEditorProps) => {
// 创建编辑器实例
const editor: Editor | null = useEditor({
extensions: [
StarterKit.configure({
hardBreak: {
// 强制换行
keepMarks: false,
},
paragraph: {
// 配置段落,避免生成多余的空段落
HTMLAttributes: {
class: 'text-knowledge-tiptap-editor-paragraph',
},
},
}),
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
Image.configure({
inline: false,
allowBase64: true,
}),
],
content: getRenderHtmlContent(chunk?.content || ''),
parseOptions: {
preserveWhitespace: 'full',
},
onUpdate: ({ editor: editorInstance }) => {
if (!chunk || !editorInstance) {
return;
}
const newContent = getEditorContent(editorInstance);
onChange?.({
...chunk,
content: newContent,
});
},
editorProps: {
...editorProps,
handlePaste(view, event, slice) {
if (!editor) {
return false;
}
const text = event.clipboardData?.getData('text/plain');
// 如果粘贴的纯文本中包含换行符
if (text?.includes('\n')) {
event.preventDefault(); // 阻止默认粘贴行为
const html = getRenderHtmlContent(text);
// 将转换后的 HTML 插入编辑器
editor.chain().focus().insertContent(html).run();
return true; // 表示我们已处理
}
return false; // 使用默认行为
},
},
});
// 当激活的分片改变时,更新编辑器内容
useEffect(() => {
if (!editor || !chunk) {
return;
}
const htmlContent = getRenderHtmlContent(chunk.content || '');
// 设置内容,保留换行符
editor.commands.setContent(htmlContent || '', false, {
preserveWhitespace: 'full',
});
}, [chunk, editor]);
return {
editor,
};
};

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, useEffect, useCallback } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { createLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useDeleteChunk } from '../inner/use-delete-chunk';
interface UsePreviewContextMenuProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onActiveChunkChange?: (chunk: Chunk) => void;
onAddChunk?: (chunk: Chunk) => void;
}
// eslint-disable-next-line max-lines-per-function
export const usePreviewContextMenu = ({
chunks,
documentId,
onChunksChange,
onActiveChunkChange,
onAddChunk,
}: UsePreviewContextMenuProps) => {
// 使用ref保存最新的chunks引用
const chunksRef = useRef(chunks);
const { deleteSlice } = useDeleteChunk();
// 每次props.chunks更新时更新ref
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
// 激活特定分片的编辑模式
const handleActivateEditMode = useCallback(
(chunk: Chunk) => {
onActiveChunkChange?.(chunk);
},
[onActiveChunkChange],
);
// 在特定分片前添加新分片
const handleAddChunkBefore = useCallback(
(chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
if (index === -1) {
return;
}
const newChunk = createLocalChunk({
sequence,
});
const updatedChunks = [
...currentChunks.slice(0, index),
newChunk,
...currentChunks.slice(index),
];
onChunksChange?.(updatedChunks);
// 自动激活新分片的编辑模式
onActiveChunkChange?.(newChunk);
onAddChunk?.(newChunk);
},
[onChunksChange, onActiveChunkChange, documentId, onAddChunk],
);
// 在特定分片后添加新分片
const handleAddChunkAfter = useCallback(
(chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
if (index === -1) {
return;
}
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
const newChunk = createLocalChunk({
sequence: String(Number(sequence) + 1),
});
const updatedChunks = [
...currentChunks.slice(0, index + 1),
newChunk,
...currentChunks.slice(index + 1),
];
// 自动激活新分片的编辑模式
onActiveChunkChange?.(newChunk);
onChunksChange?.(updatedChunks);
onAddChunk?.(newChunk);
},
[onChunksChange, onActiveChunkChange, documentId, onAddChunk],
);
// 删除特定分片
const handleDeleteChunk = useCallback(
(chunk: Chunk) => {
// 从ref中获取最新的chunks
const currentChunks = chunksRef.current;
const updatedChunks = currentChunks.filter(
c =>
c.text_knowledge_editor_chunk_uuid !==
chunk.text_knowledge_editor_chunk_uuid,
);
if (!chunk.slice_id) {
return;
}
deleteSlice(chunk.slice_id).then(() => {
onChunksChange?.(updatedChunks);
});
},
[onChunksChange, deleteSlice],
);
return {
handleActivateEditMode,
handleAddChunkBefore,
handleAddChunkAfter,
handleDeleteChunk,
};
};

View File

@@ -0,0 +1,101 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { useUpdateRemoteChunk } from './use-update-remote-chunk';
import { useDeleteRemoteChunk } from './use-delete-remote-chunk';
import { useDeleteLocalChunk } from './use-delete-local-chunk';
import { useCreateLocalChunk } from './use-create-local-chunk';
export interface UseSaveChunkProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onAddChunk?: (chunk: Chunk) => void;
onUpdateChunk?: (chunk: Chunk) => void;
onDeleteChunk?: (chunk: Chunk) => void;
}
export const useSaveChunk = ({
chunks,
documentId,
onAddChunk,
onUpdateChunk,
onChunksChange,
onDeleteChunk,
}: UseSaveChunkProps) => {
const { createLocalChunk } = useCreateLocalChunk({
chunks,
documentId,
onChunksChange,
onAddChunk,
});
const { updateRemoteChunk } = useUpdateRemoteChunk({
chunks,
onChunksChange,
onUpdateChunk,
});
const { deleteLocalChunk } = useDeleteLocalChunk({
chunks,
onChunksChange,
});
const { deleteRemoteChunk } = useDeleteRemoteChunk({
chunks,
onChunksChange,
onDeleteChunk,
});
/**
* 处理远程分片的保存逻辑
*/
const saveRemoteChunk = async (chunk: Chunk) => {
if (chunk.content === '') {
await deleteRemoteChunk(chunk);
return;
}
await updateRemoteChunk(chunk);
};
/**
* 处理本地分片的保存逻辑
*/
const saveLocalChunk = async (chunk: Chunk) => {
if (chunk.content === '') {
deleteLocalChunk(chunk);
} else {
await createLocalChunk(chunk);
}
};
/**
* 保存分片的主函数
*/
const saveChunk = async (chunk: Chunk) => {
if (!chunk.local_slice_id) {
await saveRemoteChunk(chunk);
return;
}
await saveLocalChunk(chunk);
};
return {
saveChunk,
};
};

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Toast } from '@coze-arch/coze-design';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { isEditorContentChange } from '@/text-knowledge-editor/services/use-case/is-editor-content-change';
import { updateChunks } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useUpdateChunk } from '@/text-knowledge-editor/hooks/inner/use-update-chunk';
export interface UseUpdateRemoteChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
onUpdateChunk?: (chunk: Chunk) => void;
}
export const useUpdateRemoteChunk = ({
chunks,
onChunksChange,
onUpdateChunk,
}: UseUpdateRemoteChunkProps) => {
const { updateSlice } = useUpdateChunk();
/**
* 处理远程分片的更新操作
*/
const updateRemoteChunk = async (chunk: Chunk) => {
if (!chunk.slice_id) {
Toast.error('The slice ID does not exist. Please refresh the page');
return;
}
if (!isEditorContentChange(chunks, chunk)) {
onChunksChange?.(chunks);
return;
}
await updateSlice(chunk.slice_id, chunk.content ?? '');
const newChunks = updateChunks(chunks, chunk);
onUpdateChunk?.(chunk);
onChunksChange?.(newChunks);
};
return {
updateRemoteChunk,
};
};

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { type Chunk } from './types/chunk';
export { DocumentEditor } from './features/editor';
export { DocumentPreview } from './features/preview';
export { useSaveChunk } from './hooks/use-case/use-save-chunk';
export { useInitEditor } from './hooks/use-case/use-init-editor';
export { EditorToolbar } from './features/editor-toolbar';
export {
LevelTextKnowledgeEditor,
type LevelDocumentChunk,
type LevelDocumentTree,
} from './scenes/level';
export { BaseTextKnowledgeEditor } from './scenes/base';
export type { Editor } from '@tiptap/react';
// 新增组件导出
export { HoverEditBar } from './features/hover-edit-bar/hover-edit-bar';
export {
EditAction,
AddBeforeAction,
AddAfterAction,
DeleteAction,
} from './features/hover-edit-bar-actions';
// 事件总线相关导出
export {
eventBus,
createEventBus,
useEventBus,
useEventListener,
type EventTypes,
type EventTypeName,
type EventHandler,
} from './event';

View File

@@ -0,0 +1,35 @@
/*
* 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 { UploadImageMenu } from '@/text-knowledge-editor/features/editor-actions/upload-image';
import {
createEditorActionFeatureRegistry,
type EditorActionRegistry,
} from '@/text-knowledge-editor/features/editor-actions/registry';
export const editorContextActionRegistry: EditorActionRegistry = (() => {
const editorContextActionFeatureRegistry = createEditorActionFeatureRegistry(
'editor-context-actions',
);
editorContextActionFeatureRegistry.registerSome([
{
type: 'upload-image',
module: {
Component: UploadImageMenu,
},
},
]);
return editorContextActionFeatureRegistry;
})();

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createHoverEditBarActionFeatureRegistry,
type HoverEditBarActionRegistry,
} from '@/text-knowledge-editor/features/hover-edit-bar-actions/registry';
import { EditAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/edit-action';
import { DeleteAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/delete-action';
import { AddBeforeAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/add-before-action';
import { AddAfterAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/add-after-action';
export const hoverEditBarActionsContributes: HoverEditBarActionRegistry =
(() => {
const hoverEditBarActionFeatureRegistry =
createHoverEditBarActionFeatureRegistry('hover-edit-bar-actions');
hoverEditBarActionFeatureRegistry.registerSome([
{
type: 'edit',
module: {
Component: EditAction,
},
},
{
type: 'add-before',
module: {
Component: AddBeforeAction,
},
},
{
type: 'add-after',
module: {
Component: AddAfterAction,
},
},
{
type: 'delete',
module: {
Component: DeleteAction,
},
},
]);
return hoverEditBarActionFeatureRegistry;
})();

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 { BaseTextKnowledgeEditor } from './main';
export { type DocumentChunk } from './types/base-document';

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import { useSaveChunk } from '@/text-knowledge-editor/hooks/use-case/use-save-chunk';
import { useInitEditor } from '@/text-knowledge-editor/hooks/use-case/use-init-editor';
import { useEventListener } from '@/text-knowledge-editor/event';
import { DocumentPreview } from '../../features/preview';
import { DocumentEditor } from '../../features/editor';
import { type DocumentChunk } from './types/base-document';
import { previewContextMenuItemsContributes } from './preview-context-menu-items-contributes';
import { hoverEditBarActionsContributes } from './hover-edit-bar-actions-contributes';
import { editorContextActionRegistry } from './editor-context-actions-contributes';
export interface BaseTextKnowledgeEditorProps {
chunks: DocumentChunk[];
documentId: string;
readonly?: boolean;
onChange?: (chunks: DocumentChunk[]) => void;
onAddChunk?: (chunk: DocumentChunk) => void;
onDeleteChunk?: (chunk: DocumentChunk) => void;
}
export const BaseTextKnowledgeEditor = ({
chunks: initialChunks,
documentId,
readonly = false,
onChange,
onAddChunk,
onDeleteChunk,
}: BaseTextKnowledgeEditorProps) => {
const [chunks, setChunks] = useState<DocumentChunk[]>(initialChunks);
const [activeChunk, setActiveChunk] = useState<DocumentChunk | null>(null);
// 使用编辑器核心功能
const { editor } = useInitEditor({
chunk: activeChunk,
});
// 退出新增分片功能
const { saveChunk } = useSaveChunk({
chunks,
documentId,
onChunksChange: newChunks => {
onChange?.(newChunks);
setActiveChunk(null);
},
onAddChunk,
onDeleteChunk,
});
// 监听右键菜单事件
useEventListener(
'previewContextMenuItemAction',
useCallback(
({ type, newChunk, chunks: newChunks, targetChunk }) => {
if (type === 'add-after') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'add-before') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'delete') {
onDeleteChunk?.(targetChunk);
newChunks && onChange?.(newChunks);
}
if (type === 'edit') {
setActiveChunk(targetChunk);
}
},
[onDeleteChunk, onChange],
),
);
// 监听悬浮编辑栏事件
useEventListener(
'hoverEditBarAction',
useCallback(
({ type, targetChunk, chunks: newChunks, newChunk }) => {
if (type === 'add-after') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'add-before') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'delete') {
onDeleteChunk?.(targetChunk);
newChunks && onChange?.(newChunks);
}
if (type === 'edit') {
setActiveChunk(targetChunk);
}
},
[onDeleteChunk, onChange],
),
);
useEffect(() => {
setChunks(initialChunks);
}, [initialChunks]);
return (
<>
{chunks.map(chunk => (
<div key={chunk.text_knowledge_editor_chunk_uuid}>
{(() => {
if (
chunk.text_knowledge_editor_chunk_uuid ===
activeChunk?.text_knowledge_editor_chunk_uuid &&
activeChunk
) {
return (
<DocumentEditor
editor={editor}
editorContextMenuItemsRegistry={editorContextActionRegistry}
onBlur={content => {
saveChunk({
...activeChunk,
content,
});
}}
/>
);
}
return (
<DocumentPreview
chunk={chunk}
chunks={chunks}
readonly={readonly}
onActivateEditMode={setActiveChunk}
hoverEditBarActionsRegistry={hoverEditBarActionsContributes}
previewContextMenuItemsRegistry={
previewContextMenuItemsContributes
}
/>
);
})()}
</div>
))}
</>
);
};

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createPreviewContextMenuItemFeatureRegistry,
type PreviewContextMenuItemRegistry,
} from '@/text-knowledge-editor/features/preview-context-menu-items/registry';
import { EditAction } from '@/text-knowledge-editor/features/preview-context-menu-items/edit-action';
import { DeleteAction } from '@/text-knowledge-editor/features/preview-context-menu-items/delete-action';
import { AddBeforeAction } from '@/text-knowledge-editor/features/preview-context-menu-items/add-before-action';
import { AddAfterAction } from '@/text-knowledge-editor/features/preview-context-menu-items/add-after-action';
export const previewContextMenuItemsContributes: PreviewContextMenuItemRegistry =
(() => {
const previewContextMenuItemFeatureRegistry =
createPreviewContextMenuItemFeatureRegistry('preview-context-menu-items');
previewContextMenuItemFeatureRegistry.registerSome([
{
type: 'edit',
module: {
Component: EditAction,
},
},
{
type: 'add-before',
module: {
Component: AddBeforeAction,
},
},
{
type: 'add-after',
module: {
Component: AddAfterAction,
},
},
{
type: 'delete',
module: {
Component: DeleteAction,
},
},
]);
return previewContextMenuItemFeatureRegistry;
})();

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.
*/
import { type SliceInfo } from '@coze-arch/bot-api/knowledge';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export type DocumentChunk = Chunk & SliceInfo;

View File

@@ -0,0 +1,34 @@
/*
* 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 { UploadImageMenu } from '@/text-knowledge-editor/features/editor-actions/upload-image';
import {
createEditorActionFeatureRegistry,
type EditorActionRegistry,
} from '@/text-knowledge-editor/features/editor-actions/registry';
export const editorActionRegistry: EditorActionRegistry = (() => {
const editorActionFeatureRegistry =
createEditorActionFeatureRegistry('editor-actions');
editorActionFeatureRegistry.registerSome([
{
type: 'upload-image',
module: {
Component: UploadImageMenu,
},
},
]);
return editorActionFeatureRegistry;
})();

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { type LevelDocumentTreeNode } from '../../types/level-document';
export interface ActiveChunkInfo {
chunk: LevelDocumentTreeNode | null;
renderLevel: string | null;
}
/**
* 管理文档中活动的chunk
* 使用renderLevel字段来唯一标识chunk的渲染位置
*/
export const useActiveChunk = () => {
// 存储活动的chunk和它的renderLevel
const [activeChunkInfo, setActiveChunkInfo] = useState<ActiveChunkInfo>({
chunk: null,
renderLevel: null,
});
/**
* 清除活动chunk信息
*/
const clearActiveChunk = () => {
setActiveChunkInfo({
chunk: null,
renderLevel: null,
});
};
/**
* 设置活动chunk和它的renderLevel
* 在用户交互(如双击)时使用
*/
const setActiveChunkWithLevel = (chunk: LevelDocumentTreeNode) => {
if (!chunk.renderLevel) {
console.warn('Chunk does not have renderLevel field', chunk);
return;
}
setActiveChunkInfo({
chunk,
renderLevel: chunk.renderLevel,
});
};
/**
* 检查给定的chunk是否是当前活动的chunk
*/
const isActiveChunk = (renderLevel: string | undefined) => {
if (!renderLevel) {
return false;
}
return renderLevel === activeChunkInfo.renderLevel;
};
return {
activeChunkInfo,
clearActiveChunk,
setActiveChunkWithLevel,
isActiveChunk,
};
};

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { type LevelDocumentChunk } from '../../types/level-document';
export interface ActiveChunkInfo {
chunk: LevelDocumentChunk | null;
renderPath: string | null;
}
/**
* 管理文档中具有相同ID的chunk的渲染路径
* 通过为每个chunk实例分配唯一的渲染路径解决重复ID的问题
*/
export const useChunkRenderPath = () => {
// 存储活动的chunk和它的渲染路径
const [activeChunkInfo, setActiveChunkInfo] = useState<ActiveChunkInfo>({
chunk: null,
renderPath: null,
});
/**
* 设置活动chunk但不设置渲染路径
* 通常在外部逻辑中使用如usePreviewContextMenu
*/
const setActiveChunk = (chunk: LevelDocumentChunk | null) => {
setActiveChunkInfo(prev => ({
...prev,
chunk,
}));
};
/**
* 清除活动chunk信息
*/
const clearActiveChunk = () => {
setActiveChunkInfo({
chunk: null,
renderPath: null,
});
};
/**
* 设置活动chunk和它的渲染路径
* 在用户交互(如双击)时使用
*/
const setActiveChunkWithPath = (
chunk: LevelDocumentChunk,
renderPath: string,
) => {
setActiveChunkInfo({
chunk,
renderPath,
});
};
/**
* 检查给定的chunk和渲染路径是否匹配当前活动的chunk
*/
const isActiveChunk = (chunkId: string, renderPath: string) =>
chunkId === activeChunkInfo.chunk?.text_knowledge_editor_chunk_uuid &&
renderPath === activeChunkInfo.renderPath;
/**
* 为chunk生成唯一的渲染路径
*/
const generateRenderPath = (basePath: string, chunkId: string) =>
`${basePath}-${chunkId}`;
return {
activeChunkInfo,
setActiveChunk,
clearActiveChunk,
setActiveChunkWithPath,
isActiveChunk,
generateRenderPath,
};
};

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 { useScrollToSelection } from './use-scroll-to-selection';

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { createLocateChunkId } from '../../services/locate-segment';
/**
* 滚动到选中的元素
* @param selectionIDs 选中的元素ID数组
*/
export const useScrollToSelection = (selectionIDs?: string[]) => {
useEffect(() => {
if (selectionIDs?.length) {
const firstSelectedId = selectionIDs[0];
const element = document.getElementById(
createLocateChunkId(firstSelectedId),
);
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [selectionIDs]);
};

Some files were not shown because too many files have changed in this diff Show More