Merge branch 'develop' into feature/app_zy

This commit is contained in:
yingzhao
2026-05-06 14:34:01 +08:00
committed by GitHub
32 changed files with 1685 additions and 806 deletions

View File

@@ -62,6 +62,7 @@
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwindcss": "^4.1.14",
"x6-html-shape": "^0.4.9",
"xlsx": "^0.18.5",
"zustand": "^5.0.8"
},

View File

@@ -154,6 +154,19 @@ export const uploadFile = async (data: FormData, options?: UploadFileOptions) =>
});
return response as UploadFileResponse;
};
// 上传 QA 文件
export const uploadQaFile = async (data: FormData, options?: UploadFileOptions) => {
const { kb_id, parent_id, onUploadProgress, signal } = options || {};
const params: Record<string, string> = {};
if (kb_id) params.kb_id = kb_id;
if (parent_id) params.parent_id = parent_id;
const response = await request.uploadFile(`/chunks/${kb_id}/import_qa`, data, {
params,
onUploadProgress,
signal,
});
return response as UploadFileResponse;
};
// 下载文件
export const downloadFile = async (fileId: string, fileName?: string) => {
@@ -293,7 +306,10 @@ export const updateDocumentChunk = async (kb_id:string, document_id:string, doc_
const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data);
return response as any;
};
export const deleteDocumentChunk = async (kb_id: string, document_id: string, doc_id: string) => {
const response = await request.delete(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`);
return response as any;
};
// 文档块儿创建
export const createDocumentChunk = async (kb_id:string, document_id:string, data: any) => {
const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data);

View File

@@ -54,10 +54,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => {
if (values?.retrieve_type) {
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset);
fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
}
}, [values?.retrieve_type])

View File

@@ -40,7 +40,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => {
if (values?.rerank_model) {
form.setFieldsValue({ ...data })
const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
}

View File

@@ -24,6 +24,7 @@ export interface TagProps {
/** Additional CSS classes */
className?: string;
variant?: 'outline' | 'borderless'
onClick?: () => void;
}
/** Color theme mappings with text, border, and background colors */
@@ -38,9 +39,9 @@ const colors = {
}
/** Custom tag component with color themes */
const Tag: FC<TagProps> = ({ color = 'processing', children, className, variant = 'outline' }) => {
const Tag: FC<TagProps> = ({ color = 'processing', children, className, variant = 'outline', onClick }) => {
return (
<span className={`rb:inline-block rb:px-1 rb:py-0.5 rb:rounded-sm rb:text-[12px] rb:font-regular! rb:leading-4 rb:border ${colors[color]} ${className || ''} ${variant === 'borderless' ? 'rb:border-none!' : ''}`}>
<span onClick={onClick} className={`rb:inline-block rb:px-1 rb:py-0.5 rb:rounded-sm rb:text-[12px] rb:font-regular! rb:leading-4 rb:border ${colors[color]} ${className || ''} ${variant === 'borderless' ? 'rb:border-none!' : ''}`}>
{children}
</span>
)

View File

@@ -709,6 +709,8 @@ export const en = {
localFile: 'Local File',
uploadFileTypes: 'Upload PDF, TXT, DOCX, IMAGE, MEDIA and other format files',
webLink: 'Web Link',
csvFile: 'Tabular Dataset',
csvUploadFileTypes: 'Upload files in CSV format',
webLinkPlaceholder:'Please enter',
webLinkDesc: 'Only static links are supported. If the uploaded data shows as empty, the link may not be readable. One per line, with a maximum of {{count}} links at a time',
selectorTutorial: 'Selector Usage Tutorial',
@@ -1281,13 +1283,13 @@ export const en = {
hybrid: 'Hybrid Retrieval',
graph: 'Graph Retrieval',
similarity_threshold: 'Semantic similarity threshold',
similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold',
similarity_threshold_desc1: 'The minimum similarity threshold for semantic retrieval',
vector_similarity_weight: 'Semantic similarity threshold',
vector_similarity_weight_desc: 'Only return results with semantic similarity higher than this threshold',
vector_similarity_weight_desc1: 'The minimum similarity threshold for semantic retrieval',
vector_similarity_weight: 'Vector Similarity Weight',
vector_similarity_weight_desc: 'Only return results with BM25 scores above this threshold',
vector_similarity_weight_desc1: 'The minimum BM25 score threshold for word segmentation retrieval',
similarity_threshold: 'Vector Similarity Weight',
similarity_threshold_desc: 'Only return results with BM25 scores above this threshold',
similarity_threshold_desc1: 'The minimum BM25 score threshold for word segmentation retrieval',
description: 'Description',
shareVersion: 'Share Version',

View File

@@ -194,6 +194,8 @@ export const zh = {
localFile: '本地文件',
uploadFileTypes: '上传 PDF、 TXT、 DOCX、 IMAGE、 MEDIA 等格式的文件',
webLink: '网页链接',
csvFile: '表格数据集',
csvUploadFileTypes: '上传 CSV 格式的文件',
webLinkPlaceholder: '请输入',
webLinkDesc: '仅支持静态链接。如果上传的数据显示为空,则该链接可能无法读取。每行一个,一次最多{{count}}个链接',
selectorTutorial: '选择器使用教程',
@@ -283,6 +285,7 @@ export const zh = {
qaExtract: '问答对提取',
default: '默认',
customize: '自定义',
qaPrompt: 'QA 拆分引导词',
defaultSettings: '使用系统默认的参数和规则',
customSettings: '自定义设置数据处理规则',
fileName: '文件名称',
@@ -663,13 +666,13 @@ export const zh = {
hybrid: '混合检索',
graph: '图谱检索',
similarity_threshold: '语义相似度阈值',
similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果',
similarity_threshold_desc1: '语义检索的最小相似度阈值',
similarity_threshold: '向量相似度权重',
similarity_threshold_desc: '仅返回BM25分数高于此阈值的结果',
similarity_threshold_desc1: '分词检索的最小BM25分数阈值',
vector_similarity_weight: '向量相似度权重',
vector_similarity_weight_desc: '仅返回BM25分数高于此阈值的结果',
vector_similarity_weight_desc1: '分词检索的最小BM25分数阈值',
vector_similarity_weight: '语义相似度阈值',
vector_similarity_weight_desc: '仅返回语义相似度高于此阈值的结果',
vector_similarity_weight_desc1: '语义检索的最小相似度阈值',
description: '描述',
shareVersion: '分享版本',

182
web/src/vendor/x6-html-shape/index.js vendored Normal file
View File

@@ -0,0 +1,182 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:23
*/
// Patched x6-html-shape: replaces View.createElement (removed in X6 3.x) with document.createElement
import { Node as p, NodeView as l, Graph as C, Dom as s } from "@antv/x6";
import { getConfig as w, clickable as x, isInputElement as y, forwardEvent as S } from "./utils.js";
const u = "html-shape", h = "html-shape-view", T = p.define(w(h)), m = {};
export function register(i) {
const { shape: e, render: n, inherit: t = u, ...o } = i;
if (!e) throw new Error("should specify shape in config");
m[e] = n;
C.registerNode(e, { inherit: t, ...o }, true);
}
const a = "html";
// Determine which HTML layer a node belongs to.
// Parent (loop/iteration) nodes go behind the SVG layer so edges render above them.
// All other nodes go in front of the SVG layer so they render above edges.
function isBackNode(cell) {
const type = cell.getData?.()?.type;
return type === 'loop' || type === 'iteration';
}
// Ensure the two HTML container layers exist and are correctly positioned.
function ensureHtmlLayers(graph) {
if (!graph._htmlBack) {
const back = graph._htmlBack = document.createElement('div');
s.css(back, {
position: 'absolute', width: '100%', height: '100%',
'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
'z-index': 0, 'transform-origin': 'left top',
});
back.classList.add('x6-html-shape-container', 'x6-html-shape-back');
const svg = graph.container.querySelector('svg');
// back layer: before SVG → visually behind edges
graph.container.insertBefore(back, svg || null);
}
if (!graph._htmlFront) {
const front = graph._htmlFront = document.createElement('div');
s.css(front, {
position: 'absolute', width: '100%', height: '100%',
'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
'z-index': 0, 'transform-origin': 'left top',
});
front.classList.add('x6-html-shape-container', 'x6-html-shape-front');
// front layer: after SVG → visually above edges
graph.container.append(front);
}
// Keep legacy alias so updateHtmlContainerSize can iterate both
graph.htmlContainers = [graph._htmlBack, graph._htmlFront];
}
class BaseHTMLShapeView extends l {
confirmUpdate(e) {
const n = super.confirmUpdate(e);
return this.handleAction(n, a, () => {
if (!this.mounted) {
const t = m[this.cell.shape], o = this.ensureComponentContainer();
t && o && (this.mounted = t(this.cell, this.graph, o) || true,
this.onMounted(),
o.addEventListener("mousedown", this.prevEvent, true),
o.addEventListener("mouseup", this.prevEvent, true));
}
});
}
prevEvent(e) {
(x(e.target) || y(e.target)) && (e.preventDefault(), e.stopPropagation());
}
ensureComponentContainer() {}
onMounted() {}
onUnMount() {
if (this.onZIndexChange) {
this.cell.off("change:zIndex", this.onZIndexChange);
}
if (this.onNodeMoving) {
this.graph.off("node:moving", this.onNodeMoving);
}
}
unmount() {
typeof this.mounted == "function" && this.mounted();
this.componentContainer && this.componentContainer.remove();
this.onUnMount();
return super.unmount(), this;
}
}
BaseHTMLShapeView.config({ bootstrap: [a], actions: { component: a } });
class HTMLShapeView extends BaseHTMLShapeView {
constructor(...e) {
super(...e);
this.cell.on("change:visible", ({ cell: n }) => {
if (n.view === h) {
const t = this.graph.findViewByCell(n.id);
t && Promise.resolve().then(() => {
t.componentContainer.style.display = t.container.style.display;
});
}
});
}
onMounted() {
const listeners = this.graph.listeners;
// Always register per-cell zIndex listener regardless of shared transform events
this.onZIndexChange = () => this.updateContainerStyle();
this.cell.on("change:zIndex", this.onZIndexChange);
if (listeners?.hasTransformEvent?.length) return;
this.onTranslate = this.updateHtmlContainerSize.bind(this);
this.graph.on("translate", this.onTranslate);
this.graph.on("scale", this.onTranslate);
this.graph.on("node:change:position", this.onTranslate);
this.graph.on("hasTransformEvent", this.onTranslate);
// While dragging, lift this node's componentContainer to the top of its
// layer so its ports are never obscured by a sibling node underneath.
this.onNodeMoving = ({ node }) => {
if (node === this.cell && this.componentContainer) {
const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
layer.append(this.componentContainer);
}
};
this.graph.on("node:moving", this.onNodeMoving);
this.updateHtmlContainerSize();
}
ensureComponentContainer() {
ensureHtmlLayers(this.graph);
const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
if (!this.componentContainer) {
const e = this.componentContainer = document.createElement("div");
s.css(e, {
"pointer-events": "auto", "touch-action": "none", "user-select": "none",
"transform-origin": "center", position: "absolute"
});
e.classList.add("x6-html-shape-node");
"click,dblclick,contextmenu,mousedown,mousemove,mouseup,mouseover,mouseout,mouseenter,mouseleave"
.split(",").forEach(t => S(t, e, this.container));
layer.append(e);
}
return this.componentContainer;
}
resize() { super.resize(); this.updateContainerStyle(); }
updateTransform() { super.updateTransform(); this.updateContainerStyle(); }
updateContainerStyle() {
const e = this.ensureComponentContainer();
const { x: n, y: t } = this.cell.getBBox();
const { width: o, height: r } = this.cell.getSize();
const g = getComputedStyle(this.container).cursor;
const f = this.cell.getZIndex() ?? 0;
// Shrink the interactive width by the port hover radius (6px) so the right
// port circle is fully outside the componentContainer and never blocked by it.
// overflow:visible keeps the visual rendering intact.
const PORT_RADIUS = 6;
s.css(e, {
cursor: g, height: r + "px", width: (o - PORT_RADIUS) + "px",
overflow: "visible",
"z-index": f,
transform: `translate(${n}px, ${t}px) rotate(${this.cell.getAngle()}deg)`
});
}
updateHtmlContainerSize() {
const { graph: e } = this;
const t = e.transform.getMatrix();
const { offsetHeight: o, offsetWidth: r } = e.container;
const n = e.transform.getZoom();
const style = {
transform: `matrix(${t.a}, ${t.b}, ${t.c}, ${t.d}, ${t.e}, ${t.f})`,
width: r / n + "px",
height: o / n + "px",
};
// Update both layers
(e.htmlContainers || [e._htmlBack, e._htmlFront].filter(Boolean)).forEach(c => s.css(c, style));
}
}
l.registry.register(h, HTMLShapeView, true);
p.registry.register(u, T, true);
export { BaseHTMLShapeView, T as HTMLShape, u as HTMLShapeName, HTMLShapeView, h as HTMLView, a as action };

7
web/src/vendor/x6-html-shape/react.js vendored Normal file
View File

@@ -0,0 +1,7 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:26
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:26
*/
export { default } from "x6-html-shape/dist/react.js";

104
web/src/vendor/x6-html-shape/utils.js vendored Normal file
View File

@@ -0,0 +1,104 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-06 11:54:29
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:54:29
*/
import { Dom as u, ObjectExt as l, Markup as c } from "@antv/x6";
const o = "fo-shape-view";
function p(t, e, r) {
e.addEventListener(t, function(n) {
r.dispatchEvent(new n.constructor(n.type, n)), n.preventDefault(), n.stopPropagation();
});
}
function s(t, e = 3) {
return !t || !u.isHTMLElement(t) || e <= 0 ? !1 : ["a", "button"].includes(u.tagName(t)) || t.getAttribute("role") === "button" || t.getAttribute("type") === "button" ? !0 : s(t.parentNode, e - 1);
}
function g(t) {
if (u.tagName(t) === "input") {
const r = t.getAttribute("type");
if (r == null || ["text", "password", "number", "email", "search", "tel", "url"].includes(
r
))
return !0;
}
return !1;
}
function f(t = "rect", e = !0) {
return [
{
tagName: t,
selector: "body"
},
e ? c.getForeignObjectMarkup() : null,
{
tagName: "text",
selector: "label"
}
].filter((r) => r);
}
function b(t) {
return {
view: t,
markup: f("rect", t === o),
attrs: {
body: {
// fill: "none",
// 这里很奇怪none的时候不能触发节点移动改成transparent可以触发
fill: "transparent",
stroke: "none",
refWidth: "100%",
refHeight: "100%"
},
label: {
fontSize: 14,
fill: "#333",
refX: "50%",
refY: "50%",
textAnchor: "middle",
textVerticalAnchor: "middle"
},
fo: {
refWidth: "100%",
refHeight: "100%"
}
},
propHooks(e) {
if (e.markup == null) {
const { primer: r, view: n } = e;
if (r && r !== "rect") {
e.markup = f(r, n === o);
let i = {};
r === "circle" ? i = {
refCx: "50%",
refCy: "50%",
refR: "50%"
} : r === "ellipse" && (i = {
refCx: "50%",
refCy: "50%",
refRx: "50%",
refRy: "50%"
}), e.attrs = l.merge(
{},
{
body: {
refWidth: null,
refHeight: null,
...i
}
},
e.attrs || {}
);
}
}
return e;
}
};
}
export {
o as FOView,
s as clickable,
p as forwardEvent,
b as getConfig,
g as isInputElement
};

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:25:37
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 22:35:08
* @Last Modified time: 2026-04-29 17:21:46
*/
/**
* Knowledge Configuration Modal
@@ -91,10 +91,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => {
if (values?.retrieve_type) {
const fieldsToReset = Object.keys(values).filter(key =>
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset);
fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
}
}, [values?.retrieve_type])
@@ -127,7 +131,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.retrieve_type_desc')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
options={retrieveTypes.map(key => ({
label: t(`application.${key}`),
@@ -150,33 +154,35 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
onChange={(value) => form.setFieldValue('top_k', value)}
/>
</FormItem>
{/* Semantic similarity threshold */}
{/* Vector similarity weight */}
{values?.retrieve_type === 'semantic' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
)}
{/* Word segmentation matching threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="vector_similarity_weight"
label={t('application.vector_similarity_weight')}
extra={t('application.vector_similarity_weight_desc')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* Semantic similarity threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
@@ -189,10 +195,11 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.similarity_threshold_desc1')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
<FormItem
@@ -201,10 +208,11 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.vector_similarity_weight_desc1')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
</>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:25:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-07 17:03:22
* @Last Modified time: 2026-04-29 17:21:05
*/
/**
* Knowledge Global Configuration Modal
@@ -67,7 +67,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => {
if (values?.rerank_model) {
form.setFieldsValue({ ...data })
const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
}

View File

@@ -10,7 +10,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd';
import UploadFiles from '@/components/Upload/UploadFiles';
import type { UploadRequestOption } from 'rc-upload/lib/interface';
import { uploadFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import { uploadFile, uploadQaFile, getDocumentList, parseDocument, updateDocument, deleteDocument, createDocumentAndUpload } from '@/api/knowledgeBase';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import SliderInput from '@/components/SliderInput';
@@ -38,7 +38,7 @@ const { TextArea } = Input;
});
type SourceType = 'local' | 'link' | 'text';
type SourceType = 'local' | 'link' | 'text' | 'csv';
type ProcessingMethod = 'directBlock' | 'qaExtract';
type ParameterSettings = 'defaultSettings' | 'customSettings';
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
@@ -63,6 +63,8 @@ interface ContentFormData {
title: string;
content: string;
}
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt', 'png', 'jpg', 'mp3', 'mp4', 'mov', 'wav']
const csvFileType = ['csv']
const CreateDataset = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -91,11 +93,12 @@ const CreateDataset = () => {
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
const [blockSize, setBlockSize] = useState<number>(130);
const [qaPrompt, setQaPrompt] = useState<string | undefined>()
console.log('qaPrompt', qaPrompt)
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
const [pdfEnhancementEnabled, setPdfEnhancementEnabled] = useState<boolean>(true);
const [pdfEnhancementMethod, setPdfEnhancementMethod] = useState<string>('mineru');
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg','mp3','mp4','mov','wav']
const steps = useMemo(
() => [
{ title: t('knowledgeBase.selectFile') },
@@ -112,8 +115,11 @@ const CreateDataset = () => {
const handleNext = async () => {
// Temporarily hide step 3: adjust step index (0->1->2 corresponds to select file->parameter settings->confirm upload)
let nextStep = current + 1;
if (current === 0 && source === 'csv') {
return
}
if(nextStep === 1 && source === 'local') {
if((nextStep === 1 && source === 'local') || (nextStep === 2 && source === 'csv')) {
// Check if files have been uploaded
if (rechunkFileIds.length === 0) {
// If no files, prompt user to upload first
@@ -159,6 +165,7 @@ const CreateDataset = () => {
delimiter: delimiter,
chunk_token_num: blockSize,
auto_questions: processingMethod === 'directBlock' ? 0 : 1,
qa_prompt: qaPrompt
}
}
updateDocument(id, params)
@@ -378,40 +385,67 @@ const CreateDataset = () => {
formData.append('parent_id', parentId);
}
uploadFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
signal: abortController.signal,
onUploadProgress: (event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.({ percent }, file);
},
})
.then((res: UploadFileResponse) => {
// Upload successful, remove AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
setRechunkFileIds((prev) => {
if (prev.includes(res.id)) return prev;
const next = [...prev, res.id];
return next;
});
}
if (source === 'csv') {
uploadQaFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
signal: abortController.signal,
})
.catch((error) => {
// Remove AbortController
abortControllersRef.current.delete(fileUid);
// If user actively cancelled, don't show error message
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('Upload cancelled:', (file as File).name);
return;
}
onError?.(error as Error);
});
.then((res: UploadFileResponse) => {
// Upload successful, remove AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
messageApi.success(t('knowledgeBase.uploadSuccess'))
handleBack()
})
.catch((error) => {
// Remove AbortController
abortControllersRef.current.delete(fileUid);
// If user actively cancelled, don't show error message
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('Upload cancelled:', (file as File).name);
return;
}
onError?.(error as Error);
});
} else {
uploadFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
signal: abortController.signal,
onUploadProgress: (event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.({ percent }, file);
},
})
.then((res: UploadFileResponse) => {
// Upload successful, remove AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
setRechunkFileIds((prev) => {
if (prev.includes(res.id)) return prev;
const next = [...prev, res.id];
return next;
});
}
})
.catch((error) => {
// Remove AbortController
abortControllersRef.current.delete(fileUid);
// If user actively cancelled, don't show error message
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('Upload cancelled:', (file as File).name);
return;
}
onError?.(error as Error);
});
}
};
@@ -557,21 +591,21 @@ const CreateDataset = () => {
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
<div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'>
{source !== 'csv' && <div className='rb:px-24 rb:py-5 rb:bg-white rb:rounded-xl'>
<Steps current={current} items={steps} className="custom-steps" />
</div>
</div> }
<div className='rb:bg-white rb:rounded-xl rb:flex-1 rb:mt-3'>
{current === 0 && (
<div className='rb:flex rb:w-full rb:p-6'>
{source && source === 'local' && (
{source && (source === 'local' || source === 'csv') && (
<UploadFiles
ref={uploadRef}
isCanDrag={true}
fileSize={100}
multiple={true}
maxCount={99}
fileType={fileType}
multiple={source !== 'csv'}
maxCount={source === 'csv' ? 1 : 99}
fileType={source === 'csv' ? csvFileType : fileType}
customRequest={handleUpload}
onChange={(fileList) => {
console.log('File list changed:', fileList);
@@ -765,18 +799,23 @@ const CreateDataset = () => {
</Flex>
</Radio>
</Radio.Group>
{parameterSettings === 'customSettings' && (
{parameterSettings === 'customSettings' && (<>
<div className='rb:grid rb:grid-cols-2 rb:mt-5 rb-border rb:rounded-xl rb:px-6 rb:py-4 rb:gap-10'>
<div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
{t('knowledgeBase.delimiter')}
</div>
<DelimiterSelector value={delimiter} onChange={setDelimiter} />
<div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2'>
{t('knowledgeBase.delimiter')}
</div>
<DelimiterSelector value={delimiter} onChange={setDelimiter} />
</div>
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
</div>
)}
<div>
<div className='rb:w-full rb:text-[#5B6167] rb:leading-5 rb:mb-2 rb:mt-4'>
{t('knowledgeBase.qaPrompt')}
</div>
<Input.TextArea value={qaPrompt} rows={6} onChange={(e) => setQaPrompt(e.target.value)} />
</div>
</>)}
</div>
)}
@@ -853,7 +892,7 @@ const CreateDataset = () => {
{t('common.previous') || 'Prev'}
</Button>
)}
<Button
{source !== 'csv' && <Button
type='primary'
onClick={current === 2 ? handleStartUpload : handleNext}
disabled={
@@ -863,7 +902,7 @@ const CreateDataset = () => {
}
>
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
</Button>
</Button>}
</div>
</div>
</div>

View File

@@ -10,8 +10,8 @@ import { useEffect, useState, useRef, type FC } from 'react';
import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useBreadcrumbManager, type BreadcrumbPath } from '@/hooks/useBreadcrumbManager';
import { Button, Spin, message, Switch } from 'antd';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk, getFileUrl } from '@/api/knowledgeBase';
import { Button, Spin, message, Switch, App } from 'antd';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '@/api/knowledgeBase';
import type { KnowledgeBaseDocumentData, RecallTestData } from '@/views/KnowledgeBase/types';
import { formatDateTime } from '@/utils/format';
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
@@ -20,10 +20,11 @@ import SearchInput from '@/components/SearchInput';
import DocumentPreview from '@/components/DocumentPreview';
import InsertModal, { type InsertModalRef } from '../components/InsertModal';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
const imagePath = 'https://devapi.mem.redbearai.com'
import copy from 'copy-to-clipboard'
const DocumentDetails: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { message: messageApi } = App.useApp()
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const location = useLocation();
const { updateBreadcrumbs } = useBreadcrumbManager({
@@ -100,9 +101,25 @@ const DocumentDetails: FC = () => {
}, [keywords]);
const handleCopy = (value?: string) => {
if (!value) return
copy(value)
messageApi.success(t('common.copySuccess'))
}
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
return [
{
key: 'file_id',
label: 'ID',
value: <span onClick={() => handleCopy(doc.file_id)}>
{doc.file_id}
<span
className="rb:cursor-pointer rb:-mb-0.5 rb:ml-1 rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"
></span>
</span>,
},
{
key: 'file_name',
label: t('knowledgeBase.fileName') || '文件名',
@@ -210,6 +227,11 @@ const DocumentDetails: FC = () => {
}
};
const refreshChunks = () => {
let nextPage = 1;
setPage(nextPage);
ChunkList(nextPage);
}
const loadMoreChunks = () => {
const nextPage = page + 1;
setPage(nextPage);
@@ -345,8 +367,8 @@ const DocumentDetails: FC = () => {
fileName={document?.file_name}
fileExt={document?.file_ext}
height="calc(100% - 40px)"
mode="google"
showModeSwitch={true}
// mode="google"
// showModeSwitch={true}
/>
</div>
)}
@@ -387,7 +409,7 @@ const DocumentDetails: FC = () => {
<div className="rb:flex rb:h-full rb:flex-1 rb:overflow-hidden rb:bg-white rb:rounded-xl rb:border rb:border-[#DFE4ED]">
{/* Left: Document info */}
<div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'>
<div className='rb:h-full rb:border-r rb:border-[#DFE4ED] rb:p-4'>
<div className='rb:h-full rb:border-r rb:border-[#DFE4ED] rb:p-4 rb:overflow-y-auto'>
<InfoPanel
title={t('knowledgeBase.documentInfo') || '文档信息'}
items={infoItems}
@@ -407,7 +429,7 @@ const DocumentDetails: FC = () => {
{t('knowledgeBase.chunkList') || '分块列表'}
</h2>
<RecallTestResult
refresh={refreshChunks}
data={chunkList}
showEmpty={false}
hasMore={hasMore}
@@ -417,6 +439,7 @@ const DocumentDetails: FC = () => {
editable={true}
onItemClick={handleChunkClick}
parserMode={parserMode}
handleCopy={handleCopy}
/>
</div>
</div>

View File

@@ -39,6 +39,8 @@ import { formatDateTime } from '@/utils/format';
import KnowledgeGraphCard from '../components/KnowledgeGraphCard';
import { useBreadcrumbManager, type BreadcrumbItem } from '@/hooks/useBreadcrumbManager';
import './Private.css'
import Tag from '@/components/Tag'
import copy from 'copy-to-clipboard'
// Tree node data type
const Private: FC = () => {
@@ -570,7 +572,7 @@ const Private: FC = () => {
return (
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
<span
className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full"
className="rb:inline-block rb:w-1.25 rb:h-1.25 rb:mr-2 rb:rounded-full"
style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }}
></span>
<span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span>
@@ -613,6 +615,7 @@ const Private: FC = () => {
title: t('knowledgeBase.processingMode'),
dataIndex: 'parser_id',
key: 'parser_id',
width: 100,
},
{
title: t('knowledgeBase.dataSize'),
@@ -629,6 +632,11 @@ const Private: FC = () => {
)
}
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: t('common.operation'),
@@ -762,11 +770,16 @@ const Private: FC = () => {
setIsSyncing(false);
};
const handleCopy = (value: string) => {
copy(value)
messageApi.success(t('common.copySuccess'))
}
return (
<>
<div className="rb:flex rb:h-full rb:bg-white rb:rounded-xl">
{folder && (
<div className="rb:w-64 rb:py-4 rb:flex-shrink-0 rb:h-[calc(100%+40px)] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
<div className="rb:w-64 rb:py-4 rb:shrink-0 rb:h-[calc(100%+40px)] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
<FolderTree
multiple
className="customTree"
@@ -791,11 +804,15 @@ const Private: FC = () => {
<div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded"
onClick={handleEditFolder}
>
<img src={editIcon} alt="edit" className="rb:w-[14px] rb:h-[14px" />
<img src={editIcon} alt="edit" className="rb:w-3.5 rb:h-[14px" />
<span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span>
</div>
</div>
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
<Tag variant="borderless" color="default" className="rb:cursor-pointer" onClick={() => handleCopy(knowledgeBase.id)}>
ID: {knowledgeBase.id}
<span className="rb:-mb-0.5 rb:ml-1 rb:inline-block rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></span>
</Tag>
<span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span>
<span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>

View File

@@ -55,6 +55,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
title: t('knowledgeBase.customText'),
description: t('knowledgeBase.manuallyInputText')
},
{
title: t('knowledgeBase.csvFile'),
description: t('knowledgeBase.csvUploadFileTypes')
},
]
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
@@ -86,7 +90,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
// description: selected.description,
// });
// 跳转到创建数据集页面并携带来源参数
const source = value === 0 ? 'local' : value === 1 ? 'link' : 'text';
const source = value === 3 ? 'csv' : value === 0 ? 'local' : value === 1 ? 'link' : 'text';
if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
state: {
@@ -139,6 +143,12 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex>
</Radio>
<Radio value={3} style={getActiveRadioStyle(value === 3)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[2].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span>
</Flex>
</Radio>
</Radio.Group>
</div>

View File

@@ -7,11 +7,12 @@
* @LastEditTime: 2025-11-19 19:59:36
*/
import { Divider } from 'antd';
import type { ReactElement } from 'react';
export interface InfoItem {
key: string;
label: string;
value: string | number | undefined;
value: string | number | undefined | ReactElement;
icon?: string;
}

View File

@@ -266,6 +266,8 @@ const KnowledgeGraph: FC<KnowledgeGraphProps> = ({ data, loading = false }) => {
}
}, [nodes])
console.log('selectedNode', selectedNode)
return (
<Col span={24}>
<RbCard

View File

@@ -7,25 +7,28 @@
* @LastEditTime: 2025-12-22 13:47:53
*/
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
import { Skeleton } from 'antd';
import { Skeleton, Flex, Space, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { RecallTestData } from '@/views/KnowledgeBase/types';
import { NoData } from './noData';
import { formatDateTime } from '@/utils/format';
import InfiniteScroll from 'react-infinite-scroll-component';
import RbMarkdown from '@/components/Markdown';
import { useMemo } from 'react';
import { useMemo, type MouseEvent } from 'react';
import { deleteDocumentChunk } from '@/api/knowledgeBase'
interface RecallTestResultProps {
data: RecallTestData[];
showEmpty?: boolean;
hasMore?: boolean;
loadMore?: () => void;
refresh?: () => void;
loading?: boolean;
scrollableTarget?: string;
editable?: boolean; // Whether editable
onItemClick?: (item: RecallTestData, index: number) => void; // Click item callback
parserMode?: number; // Parser mode, 1 means QA format
handleCopy?: (text?: string) => void;
}
const RecallTestResult = ({
@@ -33,13 +36,17 @@ const RecallTestResult = ({
showEmpty = true,
hasMore = false,
loadMore,
refresh,
loading = false,
scrollableTarget,
editable = false,
onItemClick,
parserMode = 0,
handleCopy,
}: RecallTestResultProps) => {
const { t } = useTranslation();
const { modal, message } = App.useApp()
console.log('chunk data', data)
// Parse QA format content
const parseQAContent = (content: string) => {
@@ -130,6 +137,24 @@ const RecallTestResult = ({
return 'rb:text-[#FF5D34]';
}
};
const handleDelete = (e: MouseEvent, item: RecallTestData) => {
e.preventDefault();
e.stopPropagation();
modal.confirm({
title: t('common.confirmDeleteDesc', { name: `chunk_${item.metadata?.sort_id}` }),
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: () => {
deleteDocumentChunk(item.metadata.knowledge_id, item.metadata.document_id, item.metadata.doc_id)
.then(() => {
message.success(t('common.deleteSuccess'));
refresh?.()
})
}
})
console.log('RecallTestData', item)
}
// Show skeleton when initial loading
if (loading && data.length === 0) {
@@ -183,17 +208,21 @@ const RecallTestResult = ({
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
</span>
)}
<div className={`rb:flex rb:mt-2 rb:flex rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
<div className={`rb:flex rb:mt-2 rb:items-end rb:justify-end rb:gap-4 ${!showScore ? 'rb:w-full' : ''}`}>
<span className='rb:text-gray-800'>
<FileOutlined /> {item.metadata?.file_name || '-'}
</span>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-[2px] rb:rounded'>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#DFDFDF] rb:px-1 rb:py-0.5 rb:rounded'>
chunk_{item.metadata?.sort_id || index}
</span>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/delete.svg')] rb:hover:bg-[url('@/assets/images/common/delete_hover.svg')]"
onClick={(e) => handleDelete(e, item)}
></div>
</div>
</div>
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-white rb:rounded-lg rb:mt-2'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:break-words rb:w-full'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:wrap-break-word rb:w-full'>
{(() => {
const qaContent = parseQAContent(item.page_content);
if (qaContent) {
@@ -204,13 +233,21 @@ const RecallTestResult = ({
})()}
</div>
</div>
{item.metadata?.file_created_at && (
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'>
<span className='rb:text-gray-500 rb:text-xs'>
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
</span>
</div>
)}
<Flex align="center" justify={item.metadata?.file_created_at ? 'space-between' : 'end'} className="rb:mt-3!">
{item.metadata?.file_created_at && (
<div className='rb:flex rb:items-center rb:justify-start'>
<span className='rb:text-gray-500 rb:text-xs'>
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
</span>
</div>
)}
<Space align="center" className='rb:text-gray-500 rb:text-xs' onClick={() => handleCopy?.(item.metadata?.doc_id)}>
ID: {item.metadata?.doc_id}
<span
className="rb:cursor-pointer rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"
></span>
</Space>
</Flex>
</div>
);
})}
@@ -228,7 +265,7 @@ const RecallTestResult = ({
<div className='rb:flex rb:h-full rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
<span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>
@@ -245,12 +282,13 @@ const RecallTestResult = ({
);
}
// Otherwise use normal rendering
return (
<div className='rb:flex rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
<span className='rb:text-gray-500 rb:text-xs rb:pt-0.5'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>

View File

@@ -16,6 +16,7 @@ import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty'
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from '@/api/knowledgeBase'
import copy from 'copy-to-clipboard'
import InfiniteScroll from 'react-infinite-scroll-component';
@@ -527,6 +528,10 @@ const KnowledgeBaseManagement: FC = () => {
fetchData(1, false);
}
}, [modelTypes, query.parent_id, query.keywords, query.orderby, query.desc])
const handleCopy = (value: string) => {
copy(value)
messageApi.success(t('common.copySuccess'))
}
return (
<>
@@ -574,6 +579,8 @@ const KnowledgeBaseManagement: FC = () => {
title={item.name}
headerType="borderless"
headerClassName="rb:py-3!"
className="rb:cursor-pointer"
onClick={() => handleToDetail(item)}
extra={
<div onClick={(e) => e.stopPropagation()}>
<Dropdown
@@ -585,7 +592,7 @@ const KnowledgeBaseManagement: FC = () => {
</div>
}
>
<div className='' onClick={() => handleToDetail(item)}>
<div className=''>
<div className="rb:flex rb:text-[#5B6167] rb:h-5 rb:line-clamp-1 rb:text-sm rb:leading-5 rb:mb-3">
{/* <div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */}
<Tooltip title={item.description}>
@@ -593,6 +600,13 @@ const KnowledgeBaseManagement: FC = () => {
</Tooltip>
</div>
<Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'>
<div className="rb:cursor-pointer rb:mb-3 rb:w-full" onClick={() => handleCopy(item.id)}>
<div className="rb:text-gray-800 rb:font-medium">ID:</div>
<Flex align="center" className="rb:text-[#5B6167]">
{item.id}
<span className="rb:ml-1 rb:inline-block rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></span>
</Flex>
</div>
{item.descriptionItems?.map((description: Record<string, unknown>) => (
<div
key={description.key as string}

View File

@@ -95,7 +95,7 @@ export interface ParserConfig {
auto_keywords?: number; // 自动关键词
auto_questions?: number; // 自动问题
html4excel?: boolean; // 是否为Excel文件
graphrag: GraphragConfig; // 知识图谱生成
graphrag?: GraphragConfig; // 知识图谱生成
// Web 类型特有字段
entry_url?: string; // 入口网址
@@ -135,6 +135,7 @@ export interface KnowledgeBaseDocumentData { // 知识库文档数据
status?: number; // 状态 1 可检索 0 不可检索
created_at?: string; // 创建时间
updated_at?: string; // 更新时间
qa_prompt?: string; // 提示词
}
export interface DocumentModalRef {
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;

View File

@@ -4,180 +4,22 @@
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-30 11:55:10
*/
import { useState } from 'react';
import { Popover, Flex } from 'antd';
import clsx from 'clsx';
import { Flex } from 'antd';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../../constant';
import { useTranslation } from 'react-i18next';
const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const AddNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {};
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Handle node selection from popover and create new node replacing the add-node placeholder
const handleNodeSelect = (selectedNodeType: any) => {
graph.startBatch('add-node');
const parentBBox = node.getBBox();
const cycleId = data.cycle;
const horizontalSpacing = 0;
const id = `${selectedNodeType.type.replace(/-/g, '_') }_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: parentBBox.x + horizontalSpacing,
y: parentBBox.y - 12,
id,
data: {
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: cycleId,
parentId: data.parentId,
config: selectedNodeType.config || {}
},
});
// Add new node as child of parent node
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
parentNode.addChild(newNode, { silent: true });
}
}
const incomingEdges = graph.getIncomingEdges(node);
const outgoingEdges = graph.getOutgoingEdges(node);
const addedEdges: any[] = [];
incomingEdges?.forEach((edge: any) => {
addedEdges.push(graph.addEdge({
source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
target: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'left')?.id || 'left' },
...edgeAttrs
}));
});
outgoingEdges?.forEach((edge: any) => {
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId();
addedEdges.push(graph.addEdge({
source: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'right')?.id || 'right' },
target: { cell: edge.getTargetCellId(), port: targetPortId },
...edgeAttrs
}));
});
// Remove all add-node type nodes
graph.getNodes().forEach((n: any) => {
if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) {
n.remove();
}
});
// Automatically adjust loop node size
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (loopNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc, child) => {
const bbox = child.getBBox();
return {
minX: Math.min(acc.minX, bbox.x),
minY: Math.min(acc.minY, bbox.y),
maxX: Math.max(acc.maxX, bbox.x + bbox.width),
maxY: Math.max(acc.maxY, bbox.y + bbox.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
loopNode.prop('size', { width: newWidth, height: newHeight });
loopNode.getPorts().forEach(port => {
if (port.group === 'right' && port.args) {
loopNode.portProp(port.id!, 'args/x', newWidth);
}
});
}
}
addedEdges.forEach(e => {
const src = graph.getCellById(e.getSourceCellId());
const tgt = graph.getCellById(e.getTargetCellId());
if (src?.isNode()) src.toFront();
if (tgt?.isNode()) tgt.toFront();
});
graph.stopBatch('add-node');
setOpen(false);
};
const content = (
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: `${nodeWidth}px'` }}>
{nodeLibrary.map((category, categoryIndex) => {
const filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
);
if (filteredNodes.length === 0) return null;
return (
<div key={category.category}>
{categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{t(`workflow.${category.category}`)}
</div>
{filteredNodes.map((nodeType) => (
<div
key={nodeType.type}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onClick={() => handleNodeSelect(nodeType)}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f8ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
}}
>
<div className={`rb:size-4 rb:bg-cover ${nodeType.icon}`} />
<span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span>
</div>
))}
</div>
);
})}
</div>
);
return (
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={setOpen}
placement="bottomLeft"
<Flex
align="center"
justify="center"
gap={4}
className="rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center"
>
<Flex
align="center"
justify="center"
gap={4}
className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center', {
'rb:border-orange-500 rb:border-[3px] rb:bg-[#FCFCFD] rb:text-[#475467]': data.isSelected,
'rb:border-[#d1d5db] rb:bg-[#FCFCFD] rb:text-[#374151]': !data.isSelected
})}
>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
{data.label}
</Flex>
</Popover>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
{data.label}
</Flex>
);
};

View File

@@ -1,14 +1,49 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-30 15:14:02
* @Last Modified by: mikey.zhaopeng
* @Last Modified time: 2026-05-06 11:46:02
*/
import { useEffect, useState } from 'react';
import { Flex, Popover } from 'antd';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant';
// Shared helper: adjust loop/iteration container size to fit child nodes
export const adjustCycleContainerSize = (graph: any, cycleId: string) => {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc: any, child: any) => {
const b = child.getBBox();
return {
minX: Math.min(acc.minX, b.x),
minY: Math.min(acc.minY, b.y),
maxX: Math.max(acc.maxX, b.x + b.width),
maxY: Math.max(acc.maxY, b.y + b.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
parentNode.prop('size', { width: newWidth, height: newHeight });
parentNode.getPorts().forEach((port: any) => {
if (port.group === 'right' && port.args) {
parentNode.portProp(port.id!, 'args/x', newWidth);
}
});
// childNodes.forEach((childNode: any) => {
// childNode.off('change:position');
// childNode.on('change:position', () => adjustCycleContainerSize(graph, cycleId));
// });
}
}
};
interface PortClickHandlerProps {
graph: any;
}
@@ -16,7 +51,6 @@ interface PortClickHandlerProps {
const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const { t } = useTranslation();
const [popoverVisible, setPopoverVisible] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
const [sourceNode, setSourceNode] = useState<any>(null);
const [sourcePort, setSourcePort] = useState<string>('');
const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
@@ -24,12 +58,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
useEffect(() => {
const handlePortClick = (event: CustomEvent) => {
const { node, port, element, rect, edgeInsertion } = event.detail;
const { node, port, element, edgeInsertion } = event.detail;
setSourceNode(node);
setSourcePort(port);
setTempElement(element);
setEdgeInsertion(edgeInsertion || null);
setPopoverPosition({ x: rect.left, y: rect.top });
setPopoverVisible(true);
};
@@ -53,6 +86,68 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const newNodeType = selectedNodeType.type;
// Save add-node placeholder position before disabling history
// AddNode placeholder mode: replace the add-node placeholder with the selected node
if (sourceNodeType === 'add-node') {
const placeholderBBox = sourceNode.getBBox();
const cycleId = sourceNodeData.cycle;
const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: placeholderBBox.x,
y: placeholderBBox.y - 12,
id,
data: {
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: cycleId,
parentId: sourceNodeData.parentId,
config: selectedNodeType.config || {},
},
});
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) parentNode.addChild(newNode);
}
const incomingEdges = graph.getIncomingEdges(sourceNode);
const outgoingEdges = graph.getOutgoingEdges(sourceNode);
const addedEdges: any[] = [];
incomingEdges?.forEach((edge: any) => {
addedEdges.push(graph.addEdge({
source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
target: { cell: newNode.id, port: newNode.getPorts().find((p: any) => p.group === 'left')?.id || 'left' },
...edgeAttrs,
}));
});
outgoingEdges?.forEach((edge: any) => {
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
const targetPortId = targetCell?.getPorts?.()?.find((p: any) => p.group === 'left')?.id || edge.getTargetPortId();
addedEdges.push(graph.addEdge({
source: { cell: newNode.id, port: newNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
target: { cell: edge.getTargetCellId(), port: targetPortId },
...edgeAttrs,
}));
});
graph.getNodes().forEach((n: any) => {
if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) n.remove();
});
setTimeout(() => {
addedEdges.forEach(e => {
const src = graph.getCellById(e.getSourceCellId());
const tgt = graph.getCellById(e.getTargetCellId());
if (src?.isNode()) src.toFront();
if (tgt?.isNode()) tgt.toFront();
});
}, 50);
if (cycleId) adjustCycleContainerSize(graph, cycleId);
if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); }
setPopoverVisible(false);
return;
}
// If it's a cycle-start node, handle the add-node placeholder
let addNodePosition = null;
if (isCycleSubNode && sourceNodeType === 'cycle-start') {
const cycleId = sourceNodeData.cycle;
@@ -158,21 +253,21 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const newPorts = newNode.getPorts();
const addedCells: any[] = [newNode];
const addedEdges: any[] = [];
if (edgeInsertion) {
// Edge insertion: create source→new and new→target edges
const { targetCell, targetPort: origTargetPort } = edgeInsertion;
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
addedEdges.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
addedEdges.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
setEdgeInsertion(null);
} else if (sourcePortGroup === 'left') {
const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs }));
} else {
// Connect from right port to new node's left side
const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs }));
addedEdges.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs }));
}
// If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type
if (isCycleContainer(newNodeType)) {
const parentBBox = newNode.getBBox();
@@ -200,27 +295,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge);
}
// Adjust parent size if adding inside a cycle container
const cycleId = sourceNodeData.cycle;
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc: any, child: any) => {
const b = child.getBBox();
return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) };
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 50;
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
parentNode.prop('size', { width: newWidth, height: newHeight });
parentNode.getPorts().forEach((port: any) => {
if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth);
});
}
// Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle;
if (cycleId) {
adjustCycleContainerSize(graph, cycleId);
}
}
// toFront
const bringCycleChildrenToFront = (cycleContainerId: string) => {
@@ -245,7 +324,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
graph.enableHistory();
const history = graph.getPlugin('history') as any;
if (history) {
const batchFrame = addedCells.map((cell: any) => ({
const batchFrame = [...addedCells, ...addedEdges].map((cell: any) => ({
batch: true,
event: 'cell:added',
data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() },
@@ -316,7 +395,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (!tempElement) return null;
return (
return createPortal(
<Popover
content={content}
open={popoverVisible}
@@ -324,14 +403,12 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (!visible) handlePopoverClose();
}}
placement="right"
overlayStyle={{
position: 'fixed',
left: popoverPosition.x + 10,
top: popoverPosition.y - 10,
}}
autoAdjustOverflow
getPopupContainer={() => document.body}
>
<div />
</Popover>
<div style={{ width: '1px', height: '1px' }} />
</Popover>,
tempElement
);
};

View File

@@ -67,10 +67,14 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
useEffect(() => {
if (values?.retrieve_type) {
const resetValues: KnowledgeConfigForm = {}
const fieldsToReset = Object.keys(values).filter(key =>
key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k'
) as (keyof KnowledgeConfigForm)[];
form.resetFields(fieldsToReset);
fieldsToReset.forEach(key => {
resetValues[key] = undefined
})
form.setFieldsValue(resetValues);
}
}, [values?.retrieve_type])
@@ -91,7 +95,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
<Flex align="center" justify="space-between" className="rb:mb-6! rb-border rb:rounded-lg rb:p-[17px_16px]! rb:cursor-pointer rb:bg-[#F0F3F8] rb:text-[#212332]">
<div className="rb:text-[16px] rb:leading-5.5">
{data.name}
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167] rb:mt-2">{t('application.contains', {include_count: data.doc_num})}</div>
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167] rb:mt-2">{t('application.contains', { include_count: data.doc_num })}</div>
</div>
<div className="rb:text-[12px] rb:leading-4 rb:text-[#5B6167]">{formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss')}</div>
</Flex>
@@ -104,13 +108,12 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.retrieve_type_desc')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
options={retrieveTypes.map(key => ({
label: t(`application.${key}`),
value: key,
}))}
// onChange={handleChange}
/>
</FormItem>
{/* Top K */}
@@ -124,34 +127,18 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
style={{ width: '100%' }}
min={1}
max={20}
// onChange={(value) => form.setFieldValue('top_k', value)}
onChange={(value) => form.setFieldValue('top_k', value)}
/>
</FormItem>
{/* 语义相似度阈值 similarity_threshold */}
{/* Vector similarity weight */}
{values?.retrieve_type === 'semantic' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* 分词匹配度阈值 vector_similarity_weight */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="vector_similarity_weight"
label={t('application.vector_similarity_weight')}
extra={t('application.vector_similarity_weight_desc')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}
@@ -159,7 +146,23 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
/>
</FormItem>
)}
{/* 混合检索权重 */}
{/* similarity threshold */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
initialValue={0.5}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
isInput={true}
/>
</FormItem>
)}
{/* Hybrid retrieval weight */}
{values?.retrieve_type === 'hybrid' && (
<>
<FormItem
@@ -168,7 +171,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.similarity_threshold_desc1')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}
@@ -181,7 +184,7 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.vector_similarity_weight_desc1')}
initialValue={0.5}
>
<RbSlider
<RbSlider
max={1.0}
step={0.1}
min={0.0}

View File

@@ -47,7 +47,8 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
useEffect(() => {
if (values?.rerank_model) {
form.setFieldsValue({ ...data })
const { rerank_model, ...rest } = data;
form.setFieldsValue({ ...rest })
} else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-07 14:55:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-17 10:05:32
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-29 17:08:19
*/
import { type FC, useEffect, useState } from "react";
import { useTranslation } from 'react-i18next'
@@ -28,7 +28,6 @@ const ModelConfig: FC = () => {
if (model_id && options) {
const model = options.find(item => item.id === model_id)
setSelectedModel(model || null)
form.setFieldValue('json_output', false)
} else {
setSelectedModel(null)
}
@@ -47,6 +46,7 @@ const ModelConfig: FC = () => {
params={{ type: 'llm,chat' }}
className="rb:w-full!"
size="small"
onChange={() => form.setFieldValue('json_output', false)}
updateOptions={updateOptions}
/>
</Form.Item>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-27 14:07:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:53:21
*/
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port';
@@ -954,7 +954,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
height: 76,
shape: 'normal-node',
ports: {
groups: { left: defaultPortGroup },
groups: { left: leftPortGroup },
items: [defaultPortItems[0]],
},
}

View File

@@ -5,10 +5,12 @@
* @Last Modified time: 2026-05-06 14:30:46
*/
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
import { register } from '@antv/x6-react-shape';
import { register as registerReactShape } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port';
import { App } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, createElement } from 'react';
import type { RefObject, Dispatch, SetStateAction, MutableRefObject, DragEvent } from 'react';
import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@@ -20,14 +22,16 @@ import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
import { useWorkflowStore } from '@/store/workflow';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
/**
* Props for useWorkflowGraph hook
*/
export interface UseWorkflowGraphProps {
/** Reference to the main graph container element */
containerRef: React.RefObject<HTMLDivElement>;
containerRef: RefObject<HTMLDivElement>;
/** Reference to the minimap container element */
miniMapRef: React.RefObject<HTMLDivElement>;
miniMapRef: RefObject<HTMLDivElement>;
/** Callback when features config is loaded */
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
}
@@ -39,23 +43,23 @@ export interface UseWorkflowGraphReturn {
/** Current workflow configuration */
config: WorkflowConfig | null;
/** Function to update workflow configuration */
setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>;
setConfig: Dispatch<SetStateAction<WorkflowConfig | null>>;
/** Reference to the X6 graph instance */
graphRef: React.MutableRefObject<Graph | undefined>;
graphRef: MutableRefObject<Graph | undefined>;
/** Currently selected node */
selectedNode: Node | null;
/** Function to update selected node */
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
setSelectedNode: Dispatch<SetStateAction<Node | null>>;
/** Current zoom level of the graph */
zoomLevel: number;
/** Function to update zoom level */
setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
setZoomLevel: Dispatch<SetStateAction<number>>;
/** Whether hand/pan mode is enabled */
isHandMode: boolean;
/** Function to toggle hand mode */
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
setIsHandMode: Dispatch<SetStateAction<boolean>>;
/** Handler for dropping nodes onto canvas */
onDrop: (event: React.DragEvent) => void;
onDrop: (event: DragEvent) => void;
/** Handler for clicking blank canvas area */
blankClick: () => void;
/** Handler for delete keyboard event */
@@ -77,7 +81,7 @@ export interface UseWorkflowGraphReturn {
/** Chat variables for workflow */
chatVariables: ChatVariable[];
/** Function to update chat variables */
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
setChatVariables: Dispatch<SetStateAction<ChatVariable[]>>;
handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
@@ -166,6 +170,21 @@ export const useWorkflowGraph = ({
initWorkflow()
}, [config, graphRef.current])
/**
* Assign explicit zIndex values to enforce layer order:
* parent nodes (loop/iteration) → child edges → child nodes
* Ports live inside each node's SVG container and are always above
* edges once the node zIndex is higher than the edge zIndex.
*/
const reorderCells = (graph: Graph) => {
// Safari uses x6-html-shape (dual HTML layer architecture).
// zIndex controls order within each HTML layer and SVG layer.
graph.getEdges().forEach(edge => edge.setZIndex(0));
graph.getNodes().forEach(node => {
node.setZIndex(node.getData()?.cycle ? 2 : 1);
});
};
/**
* Initialize workflow graph with nodes and edges from configuration
*/
@@ -474,24 +493,28 @@ export const useWorkflowGraph = ({
if (nodes.length > 0 || edges.length > 0) {
setTimeout(() => {
if (graphRef.current) {
graphRef.current.getNodes().forEach(node => {
if (!node.getData()?.cycle) node.toFront();
});
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
graphRef.current.getEdges().forEach(edge => {
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
edge.toFront();
}
});
graphRef.current.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
if (isSafari) {
reorderCells(graphRef.current)
} else {
graphRef.current.getNodes().forEach(node => {
if (!node.getData()?.cycle) node.toFront();
});
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
graphRef.current.getEdges().forEach(edge => {
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
edge.toFront();
}
});
graphRef.current.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
}
graphRef.current.enableHistory()
graphRef.current.cleanHistory()
}
}, 200)
}, isSafari ? 0 : 200)
} else {
graphRef.current.enableHistory()
graphRef.current.cleanHistory()
@@ -708,12 +731,33 @@ export const useWorkflowGraph = ({
* @param node - Clicked node
*/
const nodeClick = ({ node }: { node: Node }) => {
// add-node type: dispatch port:click to open node selection popover
// Must handle before blankClick() to avoid blank:click closing the popover immediately
const nodeData = node.getData()
if (nodeData?.type === 'add-node') {
const b = node.getBBox();
const screenPos = graphRef.current!.localToClient(b.x + b.width, b.y + b.height / 2);
const tempDiv = document.createElement('div');
tempDiv.style.cssText = `position:fixed;left:${screenPos.x}px;top:${screenPos.y}px;width:1px;height:1px;z-index:9999;`;
document.body.appendChild(tempDiv);
window.dispatchEvent(new CustomEvent('port:click', {
detail: {
node,
port: 'right',
element: tempDiv,
rect: { left: screenPos.x, top: screenPos.y },
edgeInsertion: null,
},
}));
return;
}
blankClick()
setTimeout(() => {
// Ignore add-node type node clicks
// Ignore add-node type node clicks
const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
if (nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
@@ -801,7 +845,8 @@ export const useWorkflowGraph = ({
const cycle = node.getData()?.cycle;
if (cycle) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle);
if (parentNode?.getData()?.isGroup) {
const parentType = parentNode?.getData()?.type;
if (parentNode?.getData()?.isGroup || (parentNode && (parentType === 'loop' || parentType === 'iteration'))) {
// Get parent node and child node bounding boxes
const parentBBox = parentNode.getBBox();
const childBBox = node.getBBox();
@@ -1027,13 +1072,37 @@ export const useWorkflowGraph = ({
/**
* Initialize X6 graph with configuration and event listeners
*/
const init = () => {
const init = async () => {
if (!containerRef.current || !miniMapRef.current) return;
// Register React shapes
nodeRegisterLibrary.forEach((item) => {
register(item);
});
// Safari: use x6-html-shape to avoid foreignObject rendering issues
if (isSafari) {
const { register: registerHtmlShape } = await import('x6-html-shape');
nodeRegisterLibrary.forEach(({ shape, width, height, component }) => {
registerHtmlShape({
shape,
width,
height,
render(node: Node, _graph: unknown, container: HTMLElement) {
const root = createRoot(container);
const doRender = () => {
root.render(createElement(component as any, { node, graph: node.model?.graph, data: node.getData() }));
};
doRender();
node.on('change:data', doRender);
return () => {
node.off('change:data', doRender);
root.unmount();
};
},
});
});
} else {
nodeRegisterLibrary.forEach((item) => {
registerReactShape(item);
});
}
const container = containerRef.current;
graphRef.current = new Graph({
@@ -1223,10 +1292,71 @@ export const useWorkflowGraph = ({
// Listen to node move event
graphRef.current.on('node:moved', nodeMoved);
if (isSafari) {
// When a parent (loop/iteration) node moves, keep child nodes in sync.
// Store each child's offset relative to the parent at drag start, then
// reapply it every frame to avoid cumulative delta errors.
const dragOffsets = new Map<string, { dx: number; dy: number }>();
graphRef.current.on('node:moving', ({ node }: { node: Node }) => {
const data = node.getData();
if (data?.type !== 'loop' && data?.type !== 'iteration') return;
const pos = node.getPosition();
const PORT_RADIUS = 6;
// Update parent componentContainer directly
const parentView = graphRef.current?.findViewByCell(node) as any;
if (parentView?.componentContainer) {
parentView.componentContainer.style.transform =
`translate(${pos.x + PORT_RADIUS}px, ${pos.y}px)`;
}
const children = graphRef.current?.getNodes().filter(child => {
const cycle = child.getData()?.cycle;
return cycle === data.id || cycle === node.id;
}) ?? [];
// First event for this drag: record offsets
if (!dragOffsets.has(node.id)) {
children.forEach(child => {
const cp = child.getPosition();
dragOffsets.set(child.id, { dx: cp.x - pos.x, dy: cp.y - pos.y });
});
}
// Apply stored offsets to keep children in place relative to parent
children.forEach(child => {
const off = dragOffsets.get(child.id);
if (!off) return;
const nx = pos.x + off.dx;
const ny = pos.y + off.dy;
child.setPosition(nx, ny);
const childView = graphRef.current?.findViewByCell(child) as any;
if (childView?.componentContainer) {
childView.componentContainer.style.transform =
`translate(${nx + PORT_RADIUS}px, ${ny}px)`;
}
});
});
graphRef.current.on('node:moved', ({ node }: { node: Node }) => {
// Clear offsets for this parent and all its children
const data = node.getData();
graphRef.current?.getNodes().forEach(child => {
const cycle = child.getData()?.cycle;
if (cycle === data?.id || cycle === node.id) dragOffsets.delete(child.id);
});
dragOffsets.delete(node.id);
nodeMoved({ node });
});
}
graphRef.current.on('node:removed', blankClick)
// When edge connected, bring connected nodes' ports to front
// When edge connected, reorder all cells to maintain correct layer order
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
if (isNew) {
if (isSafari && isNew && graphRef.current) {
reorderCells(graphRef.current);
} else if (!isSafari && isNew) {
const sourceCellId = edge.getSourceCellId()
const targetCellId = edge.getTargetCellId()
const sourceCell = graphRef.current?.getCellById(sourceCellId);
@@ -1340,7 +1470,7 @@ export const useWorkflowGraph = ({
* Creates new node at drop position
* @param event - React drag event
*/
const onDrop = (event: React.DragEvent) => {
const onDrop = (event: DragEvent) => {
if (!graphRef.current) return;
event.preventDefault();
const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
@@ -1518,7 +1648,7 @@ export const useWorkflowGraph = ({
...itemConfig,
...(data.config[key].defaultValue || {}),
knowledge_bases: knowledge_bases?.map((vo: any) => {
const kb_config = vo.config || { similarity_threshold: vo.similarity_threshold, retrieve_type: vo.retrieve_type, top_k: vo.top_k, weight: vo.weight }
const kb_config = vo.config || vo
return { kb_id: vo.kb_id || vo.id, ...kb_config, }
})
}

View File

@@ -44,6 +44,9 @@ export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'x6-html-shape': resolve(__dirname, 'src/vendor/x6-html-shape/index.js'),
'x6-html-shape/dist/react': resolve(__dirname, 'src/vendor/x6-html-shape/react.js'),
'x6-html-shape/dist/utils.js': resolve(__dirname, 'src/vendor/x6-html-shape/utils.js'),
},
},
base: './', // 使用相对路径,确保资源能正确加载