diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000000..126d32217d --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with the RAGFlow frontend (`web/`). + +## Project Overview + +RAGFlow frontend is a React/TypeScript application built with UmiJS: +- **Components**: shadcn/ui +- **Styling**: Tailwind CSS +- **State**: Zustand +- **Data Fetching**: TanStack Query (React Query) +- **i18n**: react-i18next + +## Common Commands + +```bash +npm install +npm run dev # Development server +npm run build # Production build +npm run lint # ESLint +npm run test # Jest tests +``` + +## Development Conventions + +### CSS and Layout Debugging +When fixing CSS/layout issues (especially flex truncation, ellipsis, or element sizing), **always inspect the full parent hierarchy** for `flex-shrink`, `min-width`, and `overflow` constraints before applying fixes like `min-w-0`. Do not repeatedly apply the same fix without verifying the root cause. +- Before editing, explain: (1) the full flex/container hierarchy from the target element up to the nearest non-flex ancestor, (2) what constraint is actually causing the bug, and (3) how the proposed fix addresses that root cause. + +### Scope and Boundaries +Respect explicit boundaries from the user. If the user says **"only fix the selected line"** or **"do not touch shared types/files"**, follow that instruction exactly. Do not investigate unrelated errors, modify shared schemas (e.g., `LlmSettingFieldSchema`), or refactor other files without confirmation. If a change outside the described scope seems necessary, ask for permission first. + +### Internationalization (i18n) +For translation tasks, add keys **only to the explicitly requested language files** (commonly `src/locales/zh.ts` and `src/locales/en.ts`). Do not auto-propagate changes to all language files unless the user explicitly asks. +- **Style for `en.ts`**: Sentence case — first word capitalized, rest lowercase (e.g., `referenceAnswer: 'Reference answer'`). Proper nouns remain as-is. + +### React Component Refactoring +When refactoring or extracting components, **verify layout behavior after each structural change** (especially `flex-1`, conditional rendering, or flex direction changes). Check that existing buttons, alignment, and responsive behavior remain intact. After extraction, verify: (1) all original props and behavior are preserved, (2) layout in parent contexts is identical, and (3) no syntax or type errors were introduced. + +### State Management and Data Fetching +For React Query / cache invalidation bugs, **carefully compare query keys across all consuming components and mutation hooks**. Mismatched keys (e.g., with/without `refreshCount`) are a common root cause of stale data or duplicate requests. +- Systematically: (1) list every component/hook that calls `useQuery` for this data, (2) compare their query keys character-for-character, (3) check every mutation's `onSuccess` for cache invalidation, and (4) verify no parent re-renders are remounting the observer. + +### React Patterns and Conventions +- **Prefer `requestAnimationFrame` or `useLayoutEffect`** over `setTimeout(..., 0)` for focus or DOM measurement operations. +- **Prefer `useTranslation` from `react-i18next`** over project-wrapped utilities like `useTranslate`. +- Extract complex logic into hooks or utils; keep components lean. +- Use `PascalCase` for constants and component names. +- Avoid duplicating component structures in JSX; favor render props or reusable components. diff --git a/web/src/components/indented-tree/indented-tree.tsx b/web/src/components/indented-tree/indented-tree.tsx index cb9583ccae..318a8f0268 100644 --- a/web/src/components/indented-tree/indented-tree.tsx +++ b/web/src/components/indented-tree/indented-tree.tsx @@ -1,430 +1,111 @@ -import { Rect } from '@antv/g'; -import { - Badge, - BaseBehavior, - BaseNode, - CommonEvent, - ExtensionCategory, - Graph, - NodeEvent, - Point, - Polyline, - PolylineStyleProps, - register, - subStyleProps, - treeToGraphData, -} from '@antv/g6'; -import { TreeData } from '@antv/g6/lib/types'; -import isEmpty from 'lodash/isEmpty'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; -import { useIsDarkTheme } from '../theme-provider'; +import { Graph as G6Graph, treeToGraphData } from '@antv/g6'; +import { useEffect, useMemo, useRef } from 'react'; -const rootId = 'root'; - -const COLORS = [ - '#5B8FF9', - '#F6BD16', - '#5AD8A6', - '#945FB9', - '#E86452', - '#6DC8EC', - '#FF99C3', - '#1E9493', - '#FF9845', - '#5D7092', -]; - -const TreeEvent = { - COLLAPSE_EXPAND: 'collapse-expand', - WHEEL: 'canvas:wheel', +const assignIds = (node: any, parentId: string = '', index = 0) => { + if (!node.id) node.id = parentId ? `${parentId}-${index}` : 'root'; + if (node.children) { + node.children.forEach((child: any, idx: number) => + assignIds(child, node.id, idx), + ); + } }; -class IndentedNode extends BaseNode { - static defaultStyleProps = { - ports: [ - { - key: 'in', - placement: 'right-bottom', - }, - { - key: 'out', - placement: 'left-bottom', - }, - ], - } as any; +const getNodeSize = (d: any): [number, number] => { + const text = d.id || ''; + const lines = text.split('\n'); + const maxChars = Math.max(...lines.map((l: string) => l.length), 0); + const width = Math.min(maxChars * 6 + 20, 400); + const height = Math.max(lines.length * 20 + 20, 40); + return [width, height]; +}; - constructor(options: any) { - Object.assign(options.style, IndentedNode.defaultStyleProps); - super(options); - } - - get childrenData() { - return this.attributes.context?.model.getChildrenData(this.id); - } - - getKeyStyle(attributes: any) { - const [width, height] = this.getSize(attributes); - const keyStyle = super.getKeyStyle(attributes); - return { - width, - height, - ...keyStyle, - fill: 'transparent', - }; - } - - drawKeyShape(attributes: any, container: any) { - const keyStyle = this.getKeyStyle(attributes); - return this.upsert('key', Rect, keyStyle, container); - } - - getLabelStyle(attributes: any) { - if (attributes.label === false || !attributes.labelText) return false; - return subStyleProps(this.getGraphicStyle(attributes), 'label') as any; - } - - drawIconArea(attributes: any, container: any) { - const [, h] = this.getSize(attributes); - const iconAreaStyle = { - fill: 'transparent', - height: 30, - width: 12, - x: -6, - y: h, - zIndex: -1, - }; - this.upsert('icon-area', Rect, iconAreaStyle, container); - } - - forwardEvent(target: any, type: any, listener: any) { - if (target && !Reflect.has(target, '__bind__')) { - Reflect.set(target, '__bind__', true); - target.addEventListener(type, listener); - } - } - - getCountStyle(attributes: any) { - const { collapsed, color } = attributes; - if (collapsed) { - const [, height] = this.getSize(attributes); - return { - backgroundFill: color, - cursor: 'pointer', - fill: '#fff', - fontSize: 8, - padding: [0, 10], - text: `${this.childrenData?.length}`, - textAlign: 'center', - y: height + 8, - }; - } - - return false; - } - - drawCountShape(attributes: any, container: any) { - const countStyle = this.getCountStyle(attributes); - const btn = this.upsert('count', Badge, countStyle as any, container); - - this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => { - event.stopPropagation(); - attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { - id: this.id, - collapsed: false, - }); - }); - } - - isShowCollapse(attributes: any) { - return ( - !attributes.collapsed && - Array.isArray(this.childrenData) && - this.childrenData?.length > 0 - ); - } - - getCollapseStyle(attributes: any) { - const { showIcon, color } = attributes; - if (!this.isShowCollapse(attributes)) return false; - const [, height] = this.getSize(attributes); - return { - visibility: showIcon ? 'visible' : 'hidden', - backgroundFill: color, - backgroundHeight: 12, - backgroundWidth: 12, - cursor: 'pointer', - fill: '#fff', - fontFamily: 'iconfont', - fontSize: 8, - text: '\ue6e4', - textAlign: 'center', - x: -1, // half of edge line width - y: height + 8, - }; - } - - drawCollapseShape(attributes: any, container: any) { - const iconStyle = this.getCollapseStyle(attributes); - const btn = this.upsert( - 'collapse-expand', - Badge, - iconStyle as any, - container, - ); - - this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => { - event.stopPropagation(); - attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { - id: this.id, - collapsed: !attributes.collapsed, - }); - }); - } - - getAddStyle(attributes: any) { - const { collapsed, showIcon } = attributes; - if (collapsed) return false; - const [, height] = this.getSize(attributes); - const color = '#ddd'; - const lineWidth = 1; - - return { - visibility: showIcon ? 'visible' : 'hidden', - backgroundFill: '#fff', - backgroundHeight: 12, - backgroundLineWidth: lineWidth, - backgroundStroke: color, - backgroundWidth: 12, - cursor: 'pointer', - fill: color, - fontFamily: 'iconfont', - text: '\ue664', - textAlign: 'center', - x: -1, - y: height + (this.isShowCollapse(attributes) ? 22 : 8), - }; - } - - render(attributes = this.parsedAttributes, container = this) { - super.render(attributes, container); - - this.drawCountShape(attributes, container); - - this.drawIconArea(attributes, container); - this.drawCollapseShape(attributes, container); - } +export interface GraphProps { + onRender?: (graph: G6Graph) => void; + onDestroy?: () => void; + data: any; } -class IndentedEdge extends Polyline { - getControlPoints( - attributes: Required, - sourcePoint: Point, - targetPoint: Point, - ) { - const [sx] = sourcePoint; - const [, ty] = targetPoint; - return [[sx, ty]] as any; - } -} +export const IndentedTree = (props: GraphProps) => { + const { onRender, onDestroy, data } = props; -class CollapseExpandTree extends BaseBehavior { - constructor(context: any, options: any) { - super(context, options); - this.bindEvents(); - } - - update(options: any) { - this.unbindEvents(); - super.update(options); - this.bindEvents(); - } - - bindEvents() { - const { graph } = this.context; - - graph.on(NodeEvent.POINTER_ENTER, this.showIcon); - graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon); - graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); - } - - unbindEvents() { - const { graph } = this.context; - - graph.off(NodeEvent.POINTER_ENTER, this.showIcon); - graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon); - graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); - } - - status = 'idle'; - - showIcon = (event: any) => { - this.setIcon(event, true); - }; - - hideIcon = (event: any) => { - this.setIcon(event, false); - }; - - setIcon = (event: any, show: boolean) => { - if (this.status !== 'idle') return; - const { target } = event; - const id = target.id; - const { graph, element } = this.context; - graph.updateNodeData([{ id, style: { showIcon: show } }]); - element?.draw({ animation: false, silence: true }); - }; - - onCollapseExpand = async (event: any) => { - this.status = 'busy'; - const { id, collapsed } = event; - const { graph } = this.context; - if (collapsed) await graph.collapseElement(id); - else await graph.expandElement(id); - this.status = 'idle'; - }; -} - -register(ExtensionCategory.NODE, 'indented', IndentedNode); -register(ExtensionCategory.EDGE, 'indented', IndentedEdge); -register( - ExtensionCategory.BEHAVIOR, - 'collapse-expand-tree', - CollapseExpandTree, -); - -interface IProps { - data: TreeData; - show: boolean; - style?: React.CSSProperties; -} - -function fallbackRender({ error }: FallbackProps) { - // Call resetErrorBoundary() to reset the error boundary and retry the render. - - return ( -
-

Something went wrong:

-
{error.message}
-
- ); -} - -const IndentedTree = ({ data, show, style = {} }: IProps) => { const containerRef = useRef(null); - const graphRef = useRef(null); - const assignIds = React.useCallback(function assignIds( - node: TreeData, - parentId: string = '', - index = 0, - ) { - if (!node.id) node.id = parentId ? `${parentId}-${index}` : 'root'; - if (node.children) { - node.children.forEach((child, idx) => assignIds(child, node.id, idx)); - } - }, []); - const isDark = useIsDarkTheme(); - const render = useCallback( - async (data: TreeData) => { - const graph: Graph = new Graph({ - container: containerRef.current!, - x: 60, - node: { - type: 'indented', - style: { - size: (d) => [d.id.length * 6 + 10, 20], - labelBackground: (datum) => datum.id === rootId, - labelBackgroundRadius: 0, - labelBackgroundFill: '#576286', - labelFill: isDark ? '#fff' : '#333', - // labelFill: (datum) => (datum.id === rootId ? '#fff' : '#666'), - labelText: (d) => d.style?.labelText || d.id, - labelTextAlign: (datum) => - datum.id === rootId ? 'center' : 'left', - labelTextBaseline: 'top', - color: (datum: any) => { - const depth = graph.getAncestorsData(datum.id, 'tree').length - 1; - return COLORS[depth % COLORS.length] || '#576286'; - }, - }, - state: { - selected: { - lineWidth: 0, - labelFill: '#40A8FF', - labelBackground: true, - labelFontWeight: 'normal', - labelBackgroundFill: '#e8f7ff', - labelBackgroundRadius: 10, - }, + const graphRef = useRef(); + + const options = useMemo( + () => ({ + autoFit: 'view', + node: { + style: (d: any) => ({ + labelText: d.id, + labelPlacement: 'right', + labelTextBaseline: 'top', + labelBackground: true, + fill: 'transparent', + stroke: 'transparent', + size: [0.1, 0.1], + }), + animation: { + enter: false, + }, + }, + edge: { + type: 'polyline', + style: { + radius: 4, + router: { + type: 'orth', }, }, - edge: { - type: 'indented', - style: { - radius: 16, - lineWidth: 2, - sourcePort: 'out', - targetPort: 'in', - stroke: (datum: any) => { - const depth = graph.getAncestorsData(datum.source, 'tree').length; - return COLORS[depth % COLORS.length] || 'black'; - }, - }, + animation: { + enter: false, }, - layout: { - type: 'indented', - direction: 'LR', - isHorizontal: true, - indent: 40, - getHeight: () => 20, - getVGap: () => 10, - }, - behaviors: [ - 'scroll-canvas', - 'collapse-expand-tree', - { - type: 'click-select', - enable: (event: any) => - event.targetType === 'node' && event.target.id !== rootId, - }, - ], - }); - - if (graphRef.current) { - graphRef.current.destroy(); - } - - graphRef.current = graph; - - assignIds(data); - - graph?.setData(treeToGraphData(data)); - - graph?.render(); - }, - [assignIds], + }, + layout: { + type: 'indented', + direction: 'LR', + indent: 80, + getHeight: (d: any) => getNodeSize(d)[1], + getWidth: (d: any) => getNodeSize(d)[0], + getVGap: () => 8, + }, + behaviors: [ + 'drag-canvas', + 'zoom-canvas', + 'drag-element', + 'collapse-expand', + ], + }), + [], ); useEffect(() => { - if (!isEmpty(data)) { - render(data); - } - }, [render, data]); + const graph = new G6Graph({ container: containerRef.current! }); + graphRef.current = graph; - return ( - -
- - ); + return () => { + const graph = graphRef.current; + if (graph) { + graph.destroy(); + onDestroy?.(); + graphRef.current = undefined; + } + }; + }, [onDestroy]); + + useEffect(() => { + const container = containerRef.current; + const graph = graphRef.current; + + if (!container || !graph || graph.destroyed || !data) return; + + graph.setOptions(options as any); + assignIds(data); + graph.setData(treeToGraphData(data)); + graph + .render() + .then(() => onRender?.(graph)) + .catch((error) => console.debug(error)); + }, [options, data, onRender]); + + return
; }; - -export default IndentedTree; diff --git a/web/src/components/indented-tree/next.tsx b/web/src/components/indented-tree/next.tsx deleted file mode 100644 index 43b961e0c3..0000000000 --- a/web/src/components/indented-tree/next.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Graph as G6Graph, treeToGraphData } from '@antv/g6'; -import { useEffect, useMemo, useRef } from 'react'; - -const assignIds = (node: any, parentId: string = '', index = 0) => { - if (!node.id) node.id = parentId ? `${parentId}-${index}` : 'root'; - if (node.children) { - node.children.forEach((child: any, idx: number) => - assignIds(child, node.id, idx), - ); - } -}; - -const getNodeSize = (d: any): [number, number] => { - const text = d.id || ''; - const lines = text.split('\n'); - const maxChars = Math.max(...lines.map((l: string) => l.length), 0); - const width = Math.min(maxChars * 6 + 20, 400); - const height = Math.max(lines.length * 20 + 20, 40); - return [width, height]; -}; - -export interface GraphProps { - onRender?: (graph: G6Graph) => void; - onDestroy?: () => void; - data: any; -} - -export const IndentedTree = (props: GraphProps) => { - const { onRender, onDestroy, data } = props; - - const containerRef = useRef(null); - const graphRef = useRef(); - - const options = useMemo( - () => ({ - data: treeToGraphData(data), - autoFit: 'view', - node: { - style: (d: any) => ({ - labelText: d.id, - labelPlacement: 'right', - labelTextBaseline: 'top', - labelBackground: true, - fill: 'transparent', - stroke: 'transparent', - size: [0.1, 0.1], - }), - animation: { - enter: false, - }, - }, - edge: { - type: 'polyline', - style: { - radius: 4, - router: { - type: 'orth', - }, - }, - animation: { - enter: false, - }, - }, - layout: { - type: 'indented', - direction: 'LR', - indent: 80, - getHeight: (d: any) => getNodeSize(d)[1], - getWidth: (d: any) => getNodeSize(d)[0], - getVGap: () => 8, - }, - behaviors: [ - 'drag-canvas', - 'zoom-canvas', - 'drag-element', - 'collapse-expand', - ], - }), - [data], - ); - - useEffect(() => { - const graph = new G6Graph({ container: containerRef.current! }); - graphRef.current = graph; - - return () => { - const graph = graphRef.current; - if (graph) { - graph.destroy(); - onDestroy?.(); - graphRef.current = undefined; - } - }; - }, []); - - useEffect(() => { - const container = containerRef.current; - const graph = graphRef.current; - - if (!options || !container || !graph || graph.destroyed || !data) return; - - graph.setOptions(options as any); - assignIds(data); - graph.setData(treeToGraphData(data)); - graph - .render() - .then(() => onRender?.(graph)) - .catch((error) => console.debug(error)); - }, [options, data, onRender]); - - return
; -}; diff --git a/web/src/pages/dataset/knowledge-graph/force-graph.tsx b/web/src/pages/dataset/knowledge-graph/force-graph.tsx index 2062f6bb4b..b2b3e75c43 100644 --- a/web/src/pages/dataset/knowledge-graph/force-graph.tsx +++ b/web/src/pages/dataset/knowledge-graph/force-graph.tsx @@ -13,6 +13,12 @@ const TooltipColorMap = { edge: 'text-blue-600', }; +const getMaxSize = (node: any) => { + if (!node?.size) return 32; + const size = Array.isArray(node.size) ? node.size : [node.size, node.size]; + return Math.max(size[0] || 32, size[1] || 32); +}; + interface IProps { data: any; show: boolean; @@ -100,9 +106,23 @@ const ForceGraph = ({ data, show }: IProps) => { ], layout: { type: 'combo-combined', - preventOverlap: true, - comboPadding: 1, - spacing: 100, + comboPadding: 10, + nodeSpacing: 100, + comboSpacing: 100, + layout: (comboId: string | null) => + !comboId + ? { + type: 'force', + preventOverlap: true, + gravity: 1, + factor: 4, + linkDistance: (_edge: any, source: any, target: any) => { + const sourceSize = getMaxSize(source); + const targetSize = getMaxSize(target); + return sourceSize / 2 + targetSize / 2 + 200; + }, + } + : { type: 'concentric', preventOverlap: true }, }, node: { style: { @@ -161,7 +181,7 @@ const ForceGraph = ({ data, show }: IProps) => { graph.setData(nextData); graph.render(); - }, [nextData]); + }, [isDark, nextData, tooltipId]); useEffect(() => { if (!isEmpty(data)) { diff --git a/web/src/pages/next-search/mindmap-drawer.tsx b/web/src/pages/next-search/mindmap-drawer.tsx index 0104ab7dd8..559c445e23 100644 --- a/web/src/pages/next-search/mindmap-drawer.tsx +++ b/web/src/pages/next-search/mindmap-drawer.tsx @@ -1,4 +1,4 @@ -import { IndentedTree } from '@/components/indented-tree/next'; +import { IndentedTree } from '@/components/indented-tree/indented-tree'; import { Progress } from '@/components/ui/progress'; import { Sheet, diff --git a/web/src/utils/list-filter-util.ts b/web/src/utils/list-filter-util.ts index 06fcbc733b..b9261a7324 100644 --- a/web/src/utils/list-filter-util.ts +++ b/web/src/utils/list-filter-util.ts @@ -10,14 +10,16 @@ export function groupListByType>( labelField: string, ) { const fileTypeList: FilterType[] = []; - list.forEach((x) => { - const item = fileTypeList.find((y) => y.id === x[idField]); - if (!item) { - fileTypeList.push({ id: x[idField], label: x[labelField], count: 1 }); - } else { - item.count += 1; - } - }); + if (Array.isArray(list)) { + list.forEach((x) => { + const item = fileTypeList.find((y) => y.id === x[idField]); + if (!item) { + fileTypeList.push({ id: x[idField], label: x[labelField], count: 1 }); + } else { + item.count += 1; + } + }); + } return fileTypeList; }