Feat: Search for knowledge-base-level graph nodes. (#17444)

This commit is contained in:
balibabu
2026-07-27 21:03:30 +08:00
committed by GitHub
parent 1ae1dd1edb
commit 1cce9b64f1
98 changed files with 3614 additions and 402 deletions

1
web/package-lock.json generated
View File

@@ -71,6 +71,7 @@
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"d3-force": "^3.0.0",
"dayjs": "^1.11.10",
"dompurify": "^3.3.2",
"embla-carousel-react": "^8.6.0",

View File

@@ -81,6 +81,7 @@
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"d3-force": "^3.0.0",
"dayjs": "^1.11.10",
"dompurify": "^3.3.2",
"embla-carousel-react": "^8.6.0",

View File

@@ -7,8 +7,13 @@ import {
getNodeRadius as defaultGetNodeRadius,
MinNodeRadius,
} from './node-style';
import { type ArtifactForceGraphProps, type ArtifactGraphNode } from './types';
import {
type ArtifactForceGraphProps,
type ArtifactGraphLink,
type ArtifactGraphNode,
} from './types';
import { useArtifactGraphData } from './use-artifact-graph-data';
import { useCenterGravity } from './use-center-gravity';
import { useContainerDimensions } from './use-container-dimensions';
import { useGraphHighlight } from './use-graph-highlight';
import { defaultMapNodeToValue } from './utils';
@@ -35,6 +40,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
);
const hasFittedRef = useRef(false);
const dimensions = useContainerDimensions(containerRef, show);
const hasDimensions = dimensions.width > 0 && dimensions.height > 0;
const graphData = useArtifactGraphData({
data,
@@ -43,16 +49,6 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
getNodeRadius,
});
const getBaseLinkColor = useCallback(() => {
if (typeof window === 'undefined' || !containerRef.current) {
return '#b2b5b7';
}
return window
.getComputedStyle(containerRef.current)
.getPropertyValue('--text-disabled')
.trim();
}, []);
// Resolve the controlled id back to a node object reference (highlighting relies on the node's __neighbors/__links)
const pinnedNode = useMemo(
() =>
@@ -69,12 +65,14 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
getLinkColor,
getLinkWidth,
paintNode,
} = useGraphHighlight(getBaseLinkColor, pinnedNode);
} = useGraphHighlight(containerRef, pinnedNode);
useEffect(() => {
hasFittedRef.current = false;
}, [graphData]);
useCenterGravity(fgRef, hasDimensions);
const handleEngineStop = useCallback(() => {
if (!hasFittedRef.current && fgRef.current) {
fgRef.current.zoomToFit(400);
@@ -94,12 +92,24 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
[],
);
// Hover tooltip shows the entity description; empty string hides it
const getNodeLabel = useCallback(
(node: ArtifactGraphNode) => node.description ?? '',
[],
);
// Empty label hides the tooltip, so relations without a type show nothing
const getLinkLabel = useCallback(
(link: ArtifactGraphLink) => link.type ?? '',
[],
);
return (
<div
ref={containerRef}
className={cn('flex-1 min-h-0 h-full', !show && 'hidden')}
>
{dimensions.width > 0 && dimensions.height > 0 && (
{hasDimensions && (
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
@@ -109,7 +119,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeColor={nodeColor}
nodeVal={nodeVal}
cooldownTicks={100}
nodeLabel={''}
nodeLabel={getNodeLabel}
autoPauseRedraw={false}
onEngineStop={handleEngineStop}
onNodeClick={handleNodeClick}
@@ -118,6 +128,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeCanvasObjectMode={nodeCanvasObjectMode}
linkColor={getLinkColor}
linkWidth={getLinkWidth}
linkLabel={getLinkLabel}
/>
)}
</div>

View File

@@ -20,6 +20,6 @@ export const renderNodeLabel: NonNullable<
ctx.fillStyle = `rgb(${textSecondary})`;
if (typeof graphNode.x === 'number' && typeof graphNode.y === 'number') {
ctx.fillText(label, graphNode.x, graphNode.y + radius - 2);
ctx.fillText(label, graphNode.x, graphNode.y + radius - 9);
}
};

View File

@@ -14,7 +14,7 @@ export type ArtifactGraphNode = NodeObject<IArtifactGraphEntity> & {
export type ArtifactGraphLink = LinkObject<
ArtifactGraphNode,
{ source: string; target: string }
{ source: string; target: string; type?: string }
>;
export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {

View File

@@ -51,6 +51,7 @@ export const useArtifactGraphData = ({
(relation) => ({
source: relation.from,
target: relation.to,
type: relation.type,
}),
);

View File

@@ -0,0 +1,28 @@
import { forceX, forceY } from 'd3-force';
import { useEffect, type RefObject } from 'react';
import { type ForceGraphMethods } from 'react-force-graph-2d';
import { type ArtifactGraphNode } from './types';
// Weak gravity pulling every node toward the center so disconnected
// components and isolated nodes are not flung far away by charge repulsion.
const CenterGravityStrength = 0.08;
// Register weak center gravity once the graph is mounted; the forces live
// on the d3 simulation and persist across graphData changes.
export function useCenterGravity(
fgRef: RefObject<ForceGraphMethods<ArtifactGraphNode> | undefined>,
hasDimensions: boolean,
) {
useEffect(() => {
const fg = fgRef.current;
if (!hasDimensions || !fg) return;
fg.d3Force(
'x',
forceX<ArtifactGraphNode>(0).strength(CenterGravityStrength),
);
fg.d3Force(
'y',
forceY<ArtifactGraphNode>(0).strength(CenterGravityStrength),
);
}, [fgRef, hasDimensions]);
}

View File

@@ -7,14 +7,14 @@ import {
HighlightLinkWidth,
} from './node-style';
import { type ArtifactGraphLink, type ArtifactGraphNode } from './types';
import { withAlpha } from './utils';
import { getBaseLinkColor, withAlpha } from './utils';
type PaintNodeFn = NonNullable<
ComponentProps<typeof ForceGraph2D>['nodeCanvasObject']
>;
export const useGraphHighlight = (
getBaseLinkColor: () => string,
containerRef: React.RefObject<HTMLElement>,
pinnedNode?: ArtifactGraphNode | null,
) => {
const [hoverNode, setHoverNode] = useState<ArtifactGraphNode | null>(null);
@@ -50,12 +50,12 @@ export const useGraphHighlight = (
const getLinkColor = useCallback(
(link: ArtifactGraphLink) => {
const baseColor = getBaseLinkColor();
const baseColor = getBaseLinkColor(containerRef.current);
return activeNode && !highlightLinks.has(link)
? withAlpha(baseColor, DimmedAlpha)
: baseColor;
},
[getBaseLinkColor, activeNode, highlightLinks],
[containerRef, activeNode, highlightLinks],
);
const getLinkWidth = useCallback(

View File

@@ -4,6 +4,16 @@ export const defaultMapNodeToValue = <TNode extends IArtifactGraphEntity>(
node: TNode,
): TNode => node;
export const getBaseLinkColor = (element?: HTMLElement | null): string => {
if (typeof window === 'undefined' || !element) {
return '#b2b5b7';
}
return window
.getComputedStyle(element)
.getPropertyValue('--border-default')
.trim();
};
export const withAlpha = (color: string, alpha: number): string => {
if (color.length === 7 && color.startsWith('#')) {
return (

View File

@@ -24,7 +24,7 @@ import classNames from 'classnames';
import DOMPurify from 'dompurify';
import 'katex/dist/katex.min.css';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import { Info } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -29,7 +29,7 @@ import {
} from '@/utils/chat';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import reactStringReplace from 'react-string-replace';
import { LoadingDots } from '../loading-dots';
import { Button } from '../ui/button';

View File

@@ -33,7 +33,7 @@ import { useLoadingPause } from '@/hooks/use-loading-pause';
import { cn } from '@/lib/utils';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import reactStringReplace from 'react-string-replace';
import { LoadingDots } from '../loading-dots';
import { Button } from '../ui/button';

View File

@@ -2,6 +2,7 @@
import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react';
import {
KeyboardEvent,
MouseEventHandler,
ReactNode,
forwardRef,
@@ -54,6 +55,8 @@ export type SelectWithSearchFlagProps = {
placeholder?: string;
emptyData?: string;
allowCustomValue?: boolean;
onNoMatchEnter?(searchValue: string): void;
disableAutoSelectOnEnter?: boolean;
testId?: string;
optionTestIdPrefix?: string;
};
@@ -81,6 +84,36 @@ function findLabelWithOptions(
.filter(Boolean)[0]?.label;
}
function hasMatchingOptions(
options: SelectWithSearchFlagOptionType[],
searchValue: string,
) {
const search = searchValue.trim();
if (!search) {
return true;
}
return options.some((group) => {
if (group.options) {
return group.options.some(
(option) =>
filterFn(
option.value ?? '',
search,
typeof option.label === 'string' ? [option.label] : [],
) === 1,
);
}
return (
filterFn(
group.value ?? '',
search,
group.keywords ??
(typeof group.label === 'string' ? [group.label] : []),
) === 1
);
});
}
export const SelectWithSearch = forwardRef<
React.ElementRef<typeof Button>,
SelectWithSearchFlagProps
@@ -96,6 +129,8 @@ export const SelectWithSearch = forwardRef<
placeholder = t('common.selectPlaceholder'),
emptyData = t('common.noDataFound'),
allowCustomValue = false,
onNoMatchEnter,
disableAutoSelectOnEnter = false,
testId,
optionTestIdPrefix,
},
@@ -176,6 +211,23 @@ export const SelectWithSearch = forwardRef<
[onChange],
);
const handleInputKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const keywords = searchValue.trim();
if (e.key === 'Enter' && keywords) {
if (disableAutoSelectOnEnter) {
e.preventDefault();
onNoMatchEnter?.(keywords);
setSearchValue('');
setOpen(false);
} else if (!hasMatchingOptions(options, keywords)) {
onNoMatchEnter?.(keywords);
}
}
},
[searchValue, options, onNoMatchEnter, disableAutoSelectOnEnter],
);
useEffect(() => {
setValue(val);
}, [val]);
@@ -235,6 +287,7 @@ export const SelectWithSearch = forwardRef<
className=" placeholder:text-text-disabled"
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={handleInputKeyDown}
/>
)}
<CommandList className="mt-2 outline-none">

View File

@@ -16,9 +16,13 @@ declare module '@/components/ui/tree-view' {
}
}
export function getEntityDisplayName(entity: IStructureGraphEntity) {
return entity.name ?? entity.id ?? '';
}
function normalizeEntity(entity: IStructureGraphEntity) {
const id = entity.id ?? entity.name ?? '';
const name = entity.name ?? entity.id ?? '';
const name = getEntityDisplayName(entity);
return { ...entity, id, name };
}
@@ -208,6 +212,7 @@ export function adaptKnowledgeGraphToForceGraph(
.map((relation) => ({
from: relation.from,
to: relation.to,
type: relation.type ?? '',
})),
};
}

View File

@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils';
import { Graph, IElementEvent, NodeEvent, treeToGraphData } from '@antv/g6';
import { memo, useEffect, useRef, useState } from 'react';
import { adaptMindMapToIndentedTree } from '../../utils/adapters';
import { adaptMindMapToIndentedTree } from '../adapters';
import { type MindMapG6GraphProps, type MindMapNodeValue } from './types';
interface MindMapNodeData {

View File

@@ -1,7 +1,10 @@
import ArtifactForceGraph from '@/components/artifact-force-graph';
import { TreeView, type TreeDataItem } from '@/components/ui/tree-view';
import { CompilationTemplateKind } from '@/constants/compilation';
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
import {
type IArtifactGraph,
type IArtifactGraphEntity,
} from '@/interfaces/database/dataset';
import {
type IStructureGraphTemplate,
type StructureTemplateKind,
@@ -14,7 +17,7 @@ import {
adaptTimelineToX6Data,
adaptTreeToTreeData,
filterTreeDataByKeyword,
} from '../utils/adapters';
} from './adapters';
import MindMapG6Graph from './mindmap-g6-graph';
import TimelineX6Graph from './timeline-x6-graph';
@@ -24,10 +27,13 @@ export interface ClickableNode {
source_chunk_ids?: string[];
}
const EmptyForceGraphData: IArtifactGraph = { entities: [], relations: [] };
interface RepresentationRendererProps {
template?: IStructureGraphTemplate;
searchKeyword?: string;
onNodeClick?: (node: ClickableNode) => void;
highlightNodeId?: string | null;
}
function UnsupportedPlaceholder({ kind }: { kind: StructureTemplateKind }) {
@@ -47,6 +53,7 @@ export function RepresentationRenderer({
template,
searchKeyword = '',
onNodeClick,
highlightNodeId,
}: RepresentationRendererProps) {
const handleTreeItemClick = useCallback(
(item: TreeDataItem | undefined) => {
@@ -114,6 +121,16 @@ export function RepresentationRenderer({
return [];
}, [template, searchKeyword]);
// Keep a stable reference across re-renders so the memoized ArtifactForceGraph
// does not restart its force simulation when only highlightNodeId changes
const forceGraphData = useMemo<IArtifactGraph>(
() =>
template
? adaptKnowledgeGraphToForceGraph(template)
: EmptyForceGraphData,
[template],
);
if (!template) {
return null;
}
@@ -143,10 +160,11 @@ export function RepresentationRenderer({
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
highlightNodeId={highlightNodeId}
/>
</div>
);
@@ -174,7 +192,7 @@ export function RepresentationRenderer({
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
@@ -195,7 +213,7 @@ export function RepresentationRenderer({
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}

View File

@@ -2,7 +2,7 @@ import { DagreLayout } from '@antv/layout';
import { Graph, type EdgeMetadata, type NodeMetadata } from '@antv/x6';
import { useEffect, useRef } from 'react';
import { type TimelineX6NodeData } from '../../../utils/adapters';
import { type TimelineX6NodeData } from '../../adapters';
import { type TimelineNodeValue, type TimelineX6GraphProps } from '../types';
export function useX6Graph(

View File

@@ -1,7 +1,4 @@
import {
type TimelineX6EdgeData,
type TimelineX6NodeData,
} from '../../utils/adapters';
import { type TimelineX6EdgeData, type TimelineX6NodeData } from '../adapters';
export interface TimelineNodeValue {
id: string;

View File

@@ -223,6 +223,17 @@ export const useNavigatePage = () => {
[navigate],
);
const navigateToCompilationTemplateEditNext = useCallback(
(id?: string) => () => {
if (id && id !== 'create') {
navigate(`${Routes.CompilationTemplatesEditNext}/${id}`);
} else {
navigate(Routes.CompilationTemplatesEditNext);
}
},
[navigate],
);
return {
navigateToDatasetList,
navigateToDataset,
@@ -253,5 +264,6 @@ export const useNavigatePage = () => {
navigateToModelSetting,
navigateToCompilationTemplates,
navigateToCompilationTemplate,
navigateToCompilationTemplateEditNext,
};
};

View File

@@ -139,8 +139,8 @@ export const useFetchAgentListByPage = () => {
const debouncedSearchString = useDebounce(searchString, { wait: 500 });
const { filterValue, handleFilterSubmit } = useHandleFilterSubmit();
const canvasCategory = Array.isArray(filterValue.canvasCategory)
? filterValue.canvasCategory
: [];
? (filterValue.canvasCategory[0] as string | undefined)
: undefined;
const owner = filterValue.owner;
const tags = Array.isArray(filterValue.tags) ? filterValue.tags : undefined;
@@ -148,7 +148,7 @@ export const useFetchAgentListByPage = () => {
page: pagination.current,
pageSize: pagination.pageSize,
keywords: debouncedSearchString,
canvasCategory: canvasCategory.length === 1 ? canvasCategory[0] : undefined,
canvasCategory,
ownerIds: Array.isArray(owner) ? owner : undefined,
tags,
});
@@ -197,7 +197,7 @@ export const useFetchAgentListByPage = () => {
loading,
searchString,
handleInputChange: onInputChange,
pagination: { ...pagination, total: data?.total },
pagination: { ...pagination, total: data?.total ?? 0 },
setPagination,
filterValue,
handleFilterSubmit,

View File

@@ -22,6 +22,7 @@ import {
useGetPaginationWithRouter,
useHandleSearchChange,
} from './logic-hooks';
import { AgentApiAction } from './use-agent-request';
export const enum CompilationTemplateGroupApiAction {
FetchCompilationTemplateGroups = 'fetchCompilationTemplateGroups',
@@ -225,6 +226,10 @@ export const useDeleteCompilationTemplateGroup = () => {
CompilationTemplateGroupApiAction.FetchCompilationTemplateGroups,
],
});
// The agents page lists groups merged into /agents results.
queryClient.invalidateQueries({
queryKey: [AgentApiAction.FetchAgentListByPage],
});
}
return data?.data ?? true;
},

View File

@@ -1,4 +1,5 @@
import message from '@/components/ui/message';
import { CompilationTemplateKind } from '@/constants/compilation';
import {
ICompilationTemplate,
ICompilationTemplateBuiltin,
@@ -52,6 +53,11 @@ export const CompilationTemplateKeys = {
wikiPresets: () => [CompilationTemplateApiAction.FetchWikiPresets] as const,
};
const ExcludedBuiltinKinds: string[] = [
CompilationTemplateKind.SessionEssence,
CompilationTemplateKind.SessionGraph,
];
export const useFetchCompilationTemplatesByPage = () => {
const { searchString, handleInputChange } = useHandleSearchChange();
const { pagination, setPagination } = useGetPaginationWithRouter();
@@ -129,7 +135,9 @@ export const useFetchBuiltinCompilationTemplates = () => {
gcTime: 0,
queryFn: async () => {
const { data } = await listBuiltinCompilationTemplates();
return (data?.data ?? []) as ICompilationTemplateBuiltin[];
return ((data?.data ?? []) as ICompilationTemplateBuiltin[]).filter(
(template) => !ExcludedBuiltinKinds.includes(template.kind),
);
},
},
);

View File

@@ -29,7 +29,12 @@ import kbService, {
} from '@/services/knowledge-service';
import { restAPIv1 } from '@/utils/api';
import { buildChunkHighlights } from '@/utils/document-util';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { useDebounce } from 'ahooks';
import { get } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -83,6 +88,17 @@ export const DocumentStructureKeys = {
datasetId,
documentId,
] as const,
graphWithKeywords: (
datasetId: string,
documentId: string,
keywords: string,
) =>
[
DocumentStructureApiAction.FetchDocumentStructureGraph,
datasetId,
documentId,
keywords,
] as const,
};
export const useUploadDocument = () => {
@@ -718,21 +734,30 @@ export const useFetchDocumentThumbnailsByIds = () => {
return { data, setDocumentIds };
};
export function useFetchDocumentStructureGraph() {
export function useFetchDocumentStructureGraph(keywords?: string) {
const { knowledgeId: datasetId, documentId } = useGetKnowledgeSearchParams();
const enabled = !!datasetId && !!documentId;
const trimmedKeywords = keywords?.trim();
const { data, isFetching: loading } =
useQuery<IStructureGraphResponse | null>({
queryKey: DocumentStructureKeys.graph(datasetId, documentId),
queryKey: trimmedKeywords
? DocumentStructureKeys.graphWithKeywords(
datasetId,
documentId,
trimmedKeywords,
)
: DocumentStructureKeys.graph(datasetId, documentId),
enabled,
initialData: null,
gcTime: 0,
placeholderData: keepPreviousData,
queryFn: async () => {
const { data } =
await documentStructureService.getDocumentStructureGraph(
datasetId,
documentId,
trimmedKeywords,
);
return data?.data ?? null;
},

View File

@@ -2,8 +2,11 @@ import { useHandleFilterSubmit } from '@/components/list-filter-bar/use-handle-f
import message from '@/components/ui/message';
import { ParseType } from '@/constants/knowledge';
import { ResponsePostType, ResponseType } from '@/interfaces/database/base';
import { GenerateType } from '@/pages/dataset/dataset/generate-button/constants';
import { DatasetGenerateKeys } from '@/pages/dataset/dataset/generate-button/hook';
import {
IArtifact,
IArtifactAlteration,
IArtifactGraph,
IArtifactPage,
IArtifactTopic,
@@ -18,6 +21,7 @@ import {
IWikiCommitDetail,
IWikiCommitListResponse,
} from '@/interfaces/database/dataset';
import { type IStructureGraphResponse } from '@/interfaces/database/document-structure';
import {
IFetchArtifactGraphRequestParams,
ITestRetrievalRequestBody,
@@ -27,8 +31,10 @@ import i18n from '@/locales/config';
import kbService, {
clearWiki,
deleteKnowledgeGraph,
getArtifactsAlteration,
getArtifactGraph,
getArtifactPage,
getArtifactsStructure,
getKbDetail,
getKnowledgeGraph,
getWikiCommit,
@@ -40,10 +46,12 @@ import kbService, {
listWikiCommits,
removeTag,
renameTag,
runIndex,
updateArtifactPage,
updateKb,
} from '@/services/knowledge-service';
import {
keepPreviousData,
useInfiniteQuery,
useIsMutating,
useMutation,
@@ -86,6 +94,9 @@ export const enum KnowledgeApiAction {
FetchKnowledgeList = 'fetchKnowledgeList',
RemoveKnowledgeGraph = 'removeKnowledgeGraph',
ClearWiki = 'clearWiki',
FetchDatasetStructure = 'fetchDatasetStructure',
FetchArtifactAlteration = 'fetchArtifactAlteration',
RunArtifactIndex = 'runArtifactIndex',
}
export const useKnowledgeBaseId = (): string => {
@@ -436,6 +447,28 @@ export const ArtifactTopicKeys = {
[KnowledgeApiAction.FetchArtifactTopicList, datasetId] as const,
};
export const ArtifactAlterationKeys = {
detail: (datasetId: string) =>
[KnowledgeApiAction.FetchArtifactAlteration, datasetId] as const,
};
export function useFetchArtifactAlteration() {
const knowledgeBaseId = useKnowledgeBaseId();
const { data, isFetching: loading } = useQuery<IArtifactAlteration | null>({
queryKey: ArtifactAlterationKeys.detail(knowledgeBaseId),
initialData: null,
enabled: !!knowledgeBaseId,
gcTime: 0,
queryFn: async () => {
const { data } = await getArtifactsAlteration(knowledgeBaseId);
return data?.data ?? null;
},
});
return { data, loading };
}
const wikiCommitKeys = {
list: (datasetId: string, pageType: string, slug: string) =>
[KnowledgeApiAction.FetchWikiCommits, datasetId, pageType, slug] as const,
@@ -725,7 +758,13 @@ export function useFetchKnowledgeGraph() {
export const artifactGraphKeys = {
graph: (datasetId: string, params?: IFetchArtifactGraphRequestParams) =>
[KnowledgeApiAction.FetchArtifactGraph, datasetId, params?.node] as const,
[
KnowledgeApiAction.FetchArtifactGraph,
datasetId,
params?.node,
params?.keywords,
params?.top_n,
] as const,
};
export function useFetchArtifactGraph(
@@ -737,6 +776,7 @@ export function useFetchArtifactGraph(
const { data, isFetching: loading } = useQuery<IArtifactGraph>({
queryKey: artifactGraphKeys.graph(knowledgeBaseId, params),
initialData: { entities: [], relations: [] } as IArtifactGraph,
placeholderData: keepPreviousData,
enabled: !!knowledgeBaseId && (options?.enabled ?? true),
gcTime: 0,
queryFn: async () => {
@@ -748,6 +788,51 @@ export function useFetchArtifactGraph(
return { data, loading };
}
export const DatasetStructureKeys = {
all: (datasetId: string) =>
[KnowledgeApiAction.FetchDatasetStructure, datasetId] as const,
kind: (datasetId: string, kind: string) =>
[KnowledgeApiAction.FetchDatasetStructure, datasetId, kind] as const,
kindWithKeywords: (datasetId: string, kind: string, keywords: string) =>
[
KnowledgeApiAction.FetchDatasetStructure,
datasetId,
kind,
keywords,
] as const,
};
export function useFetchDatasetStructureGraph(kind: string, keywords?: string) {
const knowledgeBaseId = useKnowledgeBaseId();
const enabled = !!knowledgeBaseId && !!kind;
const trimmedKeywords = keywords?.trim();
const { data, isFetching: loading } =
useQuery<IStructureGraphResponse | null>({
queryKey: trimmedKeywords
? DatasetStructureKeys.kindWithKeywords(
knowledgeBaseId,
kind,
trimmedKeywords,
)
: DatasetStructureKeys.kind(knowledgeBaseId, kind),
initialData: null,
enabled,
gcTime: 0,
placeholderData: keepPreviousData,
queryFn: async () => {
const { data } = await getArtifactsStructure(
knowledgeBaseId,
kind,
trimmedKeywords,
);
return (data?.data as IStructureGraphResponse | null) ?? null;
},
});
return { data, loading };
}
export function useFetchKnowledgeMetadata(kbIds: string[] = []) {
const { data, isFetching: loading } = useQuery<
Record<string, Record<string, string[]>>
@@ -841,6 +926,43 @@ export const useClearWiki = () => {
return { data, loading, clearWiki: mutateAsync };
};
export const useRunArtifactIndex = () => {
const knowledgeBaseId = useKnowledgeBaseId();
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [KnowledgeApiAction.RunArtifactIndex],
mutationFn: async () => {
const { data } = await runIndex(knowledgeBaseId, 'artifact');
if (data?.code === 0) {
message.success(i18n.t('message.operated'));
queryClient.invalidateQueries({
queryKey: ArtifactAlterationKeys.detail(knowledgeBaseId),
});
queryClient.invalidateQueries({
queryKey: ArtifactKeys.listByDataset(knowledgeBaseId),
});
queryClient.invalidateQueries({
queryKey: ArtifactTopicKeys.listByDataset(knowledgeBaseId),
});
queryClient.invalidateQueries({
queryKey: DatasetGenerateKeys.traceById(
GenerateType.Artifact,
knowledgeBaseId,
),
});
}
return data;
},
});
return { data, loading, runArtifactIndex: mutateAsync };
};
const KNOWLEDGE_LIST_PAGE_SIZE = 10;
export const KnowledgeListKeys = {

View File

@@ -33,6 +33,7 @@ export interface ISwitchForm {
import { AgentCategory } from '@/constants/agent';
import { Edge, Node } from '@xyflow/react';
import { IReference, Message } from './chat';
import { ICompilationTemplateGroup } from './compilation-template';
import { IDataset } from './dataset';
export type DSLComponents = Record<string, IOperator>;
@@ -85,6 +86,19 @@ export declare interface IFlow {
tags?: string;
}
// GET /agents merges compilation template groups into the agent list when no
// canvas_category is requested; every item carries this discriminator.
export enum AgentListItemType {
Agent = 'agent',
CompilationTemplateGroup = 'compilation_template_group',
}
export type AgentListItem =
| (IFlow & { type?: AgentListItemType.Agent })
| (ICompilationTemplateGroup & {
type: AgentListItemType.CompilationTemplateGroup;
});
export interface IFlowTemplate {
avatar: string;
canvas_type: string;

View File

@@ -289,9 +289,19 @@ export interface IArtifactGraphEntity {
source_chunk_ids?: string[];
}
export interface IArtifactAlteration {
removed: number;
newly_uploaded: number;
removed_doc_ids: string[];
newly_uploaded_doc_ids: string[];
involved_doc_ids: string[];
eligible_doc_ids: string[];
}
export interface IArtifactGraphRelation {
from: string;
to: string;
type?: string;
}
export interface IArtifactGraph {

View File

@@ -58,6 +58,8 @@ export interface IFetchArtifactTopicListRequestParams {
export interface IFetchArtifactGraphRequestParams {
node?: string;
keywords?: string;
top_n?: number;
}
export interface IUpdateArtifactPageRequestBody {

View File

@@ -465,6 +465,10 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
clearWikiTitle: 'Clear wiki',
clearWikiDescription:
'Are you sure you want to clear all wiki pages in this dataset? This action cannot be undone.',
update: 'Update',
updateTooltip:
'{{newlyUploaded}} new, {{removed}} removed documents found. Click to compile and merge into current Wiki.',
updateSheetTitle: 'Update Wiki',
noSkills: 'No skills yet',
generate: 'Generate',
raptor: 'RAPTOR',
@@ -520,8 +524,19 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
versionContentRequired: 'Please input version content',
graph: 'Graph',
graphPlaceholder: 'Graph view placeholder',
llmWiki: 'LLM Wiki',
skills: 'To Skills',
llmWiki: 'Wiki',
navTree: 'Tree',
structureGraph: 'Graph',
structureMindmap: 'Mind map',
structureTimeline: 'Timeline',
structureSessionEssence: 'Session essence',
structureSessionGraph: 'Session graph',
noStructureGraph: 'No graph yet',
noStructureMindmap: 'No mind map yet',
noStructureTimeline: 'No timeline yet',
noStructureSessionEssence: 'No session essence yet',
noStructureSessionGraph: 'No session graph yet',
contents: 'Navigation',
topics: 'Topics',
concept: 'Concept',
@@ -932,7 +947,7 @@ The above is the content you need to summarize.`,
randomSeed: 'Random seed',
randomSeedMessage: 'Random seed is required',
entityTypes: 'Entity types',
compilationTemplate: 'Compilation template',
compilationTemplate: 'Operator',
scopeFile: 'File',
vietnamese: 'Vietnamese',
pageRank: 'Page rank',
@@ -1828,8 +1843,8 @@ Example: Virtual Hosted Style`,
chatChannelsDescription: 'Manage your chat channel bots and credentials',
compilationTemplates: 'Compilation templates',
compilationTemplatesDescription: 'Manage your compilation templates',
addTemplateGroup: 'Add template group',
editTemplateGroup: 'Edit template group',
addTemplateGroup: 'Add template',
editTemplateGroup: 'Edit template',
groupName: 'Group name',
groupNameRequired: 'Please input group name',
groupDescription: 'Group description',
@@ -1848,7 +1863,7 @@ Example: Virtual Hosted Style`,
templateName: 'Name',
templateNameRequired: 'Please input template name',
templateDescription: 'Description',
llmForExtraction: 'LLM for extraction',
llmForExtraction: 'Default Model for extraction',
llmForExtractionRequired: 'Please select an LLM model',
templateKind: 'Kind',
templateKindRequired: 'Please select a kind',
@@ -1888,6 +1903,7 @@ Example: Virtual Hosted Style`,
templateWizardConfigurationDescription: 'Configure templates',
blueprints: 'Blueprints',
blueprintsDescription: 'Select required blueprints',
custom: 'Custom',
templates: 'Templates',
addFieldModalTitle: 'Add field',
editFieldModalTitle: 'Edit field',
@@ -3193,6 +3209,13 @@ This process aggregates variables from multiple branches into a single variable
copyOfAgentName: '{{name}} (copy)',
ceateAgent: 'Workflow',
createPipeline: 'Ingestion pipeline',
createIngestionPipeline: 'Create ingestion pipeline',
createWorkflow: 'Create workflow',
tabList: {
ingestionPipeline: 'Ingestion pipeline',
compilationOperator: 'Compilation operator',
workflow: 'Workflow',
},
chooseAgentType: 'Choose agent type',
parser: 'Parser',
parserDescription:
@@ -3210,9 +3233,9 @@ This process aggregates variables from multiple branches into a single variable
extractor: 'Transformer',
extractorDescription:
'Use an LLM to extract structured insights from document chunks—such as summaries, classifications, etc.',
compiler: 'Operator',
compiler: 'Compiler',
compilerDescription:
'Processes document chunks using operator templates into structured artifacts.',
'Compiles document chunks using knowledge compilation templates into structured artifacts.',
outputFormat: 'Output format',
fileFormats: 'File type',
fileFormatOptions: {

View File

@@ -1659,6 +1659,7 @@ export default {
templateWizardConfigurationDescription: 'テンプレートを設定します',
blueprints: 'ブループリント',
blueprintsDescription: '必要なブループリントを選択してください',
custom: 'カスタム',
templates: 'テンプレート',
addFieldModalTitle: 'フィールドを追加',
editFieldModalTitle: 'フィールドを編集',
@@ -2863,6 +2864,11 @@ export default {
copyOfAgentName: '{{name}} (コピー)',
ceateAgent: 'ワークフロー',
createPipeline: '取り込みパイプライン',
tabList: {
ingestionPipeline: '取り込みパイプライン',
compilationOperator: 'コンパイルオペレーター',
workflow: 'ワークフロー',
},
chooseAgentType: 'エージェントタイプを選択',
parser: 'パーサー',
parserDescription:

View File

@@ -415,6 +415,10 @@ export default {
clearWikiTitle: '清空 Wiki',
clearWikiDescription:
'确定要清空该数据集下的所有 Wiki 页面吗?此操作无法撤销。',
update: '更新',
updateTooltip:
'发现 {{newlyUploaded}} 个新文档,{{removed}} 个已移除文档。点击编译并合并到当前 Wiki。',
updateSheetTitle: '更新 Wiki',
noSkills: '暂无技能',
processingType: '处理类型',
dataPipeline: '切换或配置 ingestion pipeline。',
@@ -465,8 +469,19 @@ export default {
versionContentRequired: '请输入版本内容',
graph: '图谱',
graphPlaceholder: '图谱视图占位',
llmWiki: 'LLM Wiki',
skills: '技能',
llmWiki: 'Wiki',
navTree: '目录树',
structureGraph: '图谱',
structureMindmap: '思维导图',
structureTimeline: '时间线',
structureSessionEssence: '会话摘要',
structureSessionGraph: '会话图谱',
noStructureGraph: '暂无图谱',
noStructureMindmap: '暂无思维导图',
noStructureTimeline: '暂无时间线',
noStructureSessionEssence: '暂无会话摘要',
noStructureSessionGraph: '暂无会话图谱',
contents: '导航',
topics: '主题',
concept: '概念',
@@ -843,7 +858,7 @@ export default {
'在 RAPTOR 中,数据块会根据它们的语义相似性进行聚类。阈值设定了数据块被分到同一组所需的最小相似度。阈值越高,每个聚类中的数据块越少;阈值越低,则每个聚类中的数据块越多。',
maxClusterTip: '最多可创建的聚类数。',
entityTypes: '实体类型',
compilationTemplate: '编译模板',
compilationTemplate: '算子',
scopeFile: '文件',
pageRank: '页面排名',
pageRankTip: `知识库检索时,你可以为特定知识库设置较高的 PageRank 分数,该知识库中匹配文本块的混合相似度得分会自动叠加 PageRank 分数,从而提升排序权重。详见 https://ragflow.io/docs/dev/set_page_rank。`,
@@ -1518,8 +1533,8 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
chatChannelsDescription: '管理您的聊天渠道机器人及凭证',
compilationTemplates: '知识编译模板',
compilationTemplatesDescription: '管理您的知识编译模板',
addTemplateGroup: '添加模板分组',
editTemplateGroup: '编辑模板分组',
addTemplateGroup: '添加模板',
editTemplateGroup: '编辑模板',
groupName: '分组名称',
groupNameRequired: '请输入分组名称',
groupDescription: '分组描述',
@@ -1537,7 +1552,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
templateName: '名称',
templateNameRequired: '请输入模板名称',
templateDescription: '描述',
llmForExtraction: '用于提取的 LLM',
llmForExtraction: '默认提取模型',
llmForExtractionRequired: '请选择 LLM 模型',
templateKind: '类型',
templateKindRequired: '请选择类型',
@@ -1577,6 +1592,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
templateWizardConfigurationDescription: '管理模板的配置',
blueprints: '蓝图',
blueprintsDescription: '选择所需要的 blueprints',
custom: '自定义',
templates: '模板',
addFieldModalTitle: '添加字段',
editFieldModalTitle: '编辑字段',
@@ -2787,6 +2803,14 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
duplicate: '复制',
copyOfAgentName: '{{name}} (副本)',
chooseAgentType: '选择智能体类型',
createPipeline: '数据管道',
createIngestionPipeline: '创建数据管道',
createWorkflow: '创建工作流',
tabList: {
ingestionPipeline: '数据管道',
compilationOperator: '编译算子',
workflow: '工作流',
},
parser: '解析器',
parserDescription: '从文件中提取原始文本和结构以供下游处理。',
tokenizer: '分词器',
@@ -2802,8 +2826,8 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
extractor: '提取器',
extractorDescription:
'使用 LLM 从文档块(例如摘要、分类等)中提取结构化见解。',
compiler: '算子',
compilerDescription: '使用算子模板将文档块处理为结构化工件。',
compiler: '编译器',
compilerDescription: '使用知识编译模板将文档块编译为知识工件。',
outputFormat: '输出格式',
fileFormats: '文件类型',
fileFormatOptions: {

View File

@@ -2,7 +2,7 @@ import { useCompilationTemplateGroupOptions } from '@/hooks/use-compilation-temp
import { IRagNode } from '@/interfaces/database/agent';
import { NodeProps } from '@xyflow/react';
import { get } from 'lodash';
import { LabelCard, LLMLabelCard } from './card';
import { LabelCard } from './card';
import { RagNode } from './index';
import { useTranslation } from 'react-i18next';
@@ -17,7 +17,6 @@ export function CompilationNode({ ...props }: NodeProps<IRagNode>) {
return (
<RagNode {...props}>
<section className="flex flex-col gap-2">
<LLMLabelCard llmId={get(data, 'form.llm_id')}></LLMLabelCard>
<LabelCard className="text-text-primary flex justify-between flex-col gap-1">
<span className="text-text-secondary">
{t('knowledgeConfiguration.compilationTemplate')}

View File

@@ -362,7 +362,6 @@ export const initialExtractorValues = {
};
export const initialCompilationValues = {
...initialLlmBaseValues,
compilation_template_group_ids: [],
outputs: {
chunks: { type: 'Array<Object>', value: [] },

View File

@@ -1,13 +1,10 @@
import { CompilationTemplateFormField } from '@/components/compilation-template-form-field';
import { LargeModelFormField } from '@/components/large-model-form-field';
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { initialCompilationValues } from '../../constant/pipeline';
import { useOwnerTenantId } from '../../context';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
@@ -17,7 +14,6 @@ import { Output } from '../components/output';
export const FormSchema = z.object({
compilation_template_group_ids: z.string().optional(),
...LlmSettingSchema,
});
export type CompilationFormSchemaType = z.infer<typeof FormSchema>;
@@ -26,7 +22,6 @@ const outputList = buildOutputList(initialCompilationValues.outputs);
const CompilationForm = ({ node }: INextOperatorForm) => {
const defaultValues = useFormValues(initialCompilationValues, node);
const ownerTenantId = useOwnerTenantId();
const form = useForm<CompilationFormSchemaType>({
defaultValues,
@@ -38,9 +33,6 @@ const CompilationForm = ({ node }: INextOperatorForm) => {
return (
<Form {...form}>
<FormWrapper>
<LargeModelFormField
ownerTenantId={ownerTenantId}
></LargeModelFormField>
<CompilationTemplateFormField name="compilation_template_group_ids"></CompilationTemplateFormField>
<Output list={outputList}></Output>
</FormWrapper>

View File

@@ -183,10 +183,7 @@ export const useInitializeOperatorParams = () => {
sys_prompt: t('flow.prompts.system.summary'),
prompts: t('flow.prompts.user.summary'),
},
[Operator.Compilation]: {
...initialCompilationValues,
llm_id: llmId,
},
[Operator.Compilation]: initialCompilationValues,
[Operator.DataOperations]: initialDataOperationsValues,
[Operator.ListOperations]: initialListOperationsValues,
[Operator.VariableAssigner]: initialVariableAssignerValues,

View File

@@ -5,15 +5,41 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AgentCategory } from '@/constants/agent';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { IFlow } from '@/interfaces/database/agent';
import { Route } from 'lucide-react';
import { AgentListItemType, IFlow } from '@/interfaces/database/agent';
import { LucideIcon, Network, Route, Shapes } from 'lucide-react';
import { AgentDropdown } from './agent-dropdown';
import { useRenameAgent } from './use-rename-agent';
export type DatasetCardProps = {
data: IFlow;
data: IFlow & { type?: AgentListItemType };
} & Pick<ReturnType<typeof useRenameAgent>, 'showAgentRenameModal'>;
const CanvasCategoryIconMap: Record<string, LucideIcon> = {
[AgentCategory.AgentCanvas]: Network,
[AgentCategory.DataflowCanvas]: Route,
};
function AgentTypeIcon({
data,
}: {
data: IFlow & { type?: AgentListItemType };
}) {
const Icon =
data.type === AgentListItemType.CompilationTemplateGroup
? Shapes
: CanvasCategoryIconMap[data.canvas_category];
if (!Icon) {
return null;
}
return (
<Button variant={'ghost'} size={'sm'}>
<Icon />
</Button>
);
}
function AgentTags({ tags }: { tags?: string }) {
const list = (tags || '')
.split(',')
@@ -55,13 +81,7 @@ export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) {
// :
navigateToAgent(data?.id, data.canvas_category as AgentCategory)
}
icon={
data.canvas_category === AgentCategory.DataflowCanvas && (
<Button variant={'ghost'} size={'sm'}>
<Route />
</Button>
)
}
icon={<AgentTypeIcon data={data} />}
extra={<AgentTags tags={data.tags} />}
showReleaseTime
/>

View File

@@ -116,6 +116,7 @@ export default function AgentTemplates() {
loading={loading}
visible={creatingVisible}
hideModal={hideCreatingModal}
canvasCategory={template?.canvas_category as AgentCategory}
onOk={handleOk}
></CreateAgentDialog>
)}

View File

@@ -0,0 +1,80 @@
import { MoreButton } from '@/components/more-button';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { CompilationTemplateScope } from '@/constants/compilation';
import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template';
import { Database, FileText, LucideIcon } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { formatKindLabel } from '@/utils/compilation-template-util';
import { CompilationTemplateDropdown } from './compilation-template-dropdown';
type CompilationTemplateCardProps = {
data: ICompilationTemplateGroup;
onClick?: () => void;
onDelete: (id: string) => void;
};
const ScopeIconMap: Record<string, LucideIcon> = {
[CompilationTemplateScope.File]: FileText,
[CompilationTemplateScope.Dataset]: Database,
};
function ScopeIcon({ scope }: { scope?: string }) {
const Icon = scope ? ScopeIconMap[scope] : null;
return Icon ? <Icon className="size-4 text-text-secondary shrink-0" /> : null;
}
export function CompilationTemplateCard({
data,
onClick,
onDelete,
}: CompilationTemplateCardProps) {
const { t } = useTranslation();
const kinds = useMemo(
() => Array.from(new Set((data.templates ?? []).map((item) => item.kind))),
[data.templates],
);
return (
<Card className="group cursor-pointer h-full" onClick={onClick}>
<CardContent className="p-4 flex gap-3">
<RAGFlowAvatar
avatar={data.avatar}
name={data.name}
className="w-8 h-8 shrink-0"
/>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<section className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="text-base font-normal truncate text-text-primary">
{data.name}
</h3>
<ScopeIcon scope={data.scope} />
</div>
<CompilationTemplateDropdown data={data} onDelete={onDelete}>
<MoreButton />
</CompilationTemplateDropdown>
</section>
<p className="text-sm text-text-secondary line-clamp-1">
{data.description}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{kinds.map((kind) => (
<Badge key={kind} variant="secondary">
{formatKindLabel(t, kind)}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
import {
ConfirmDeleteDialog,
ConfirmDeleteDialogNode,
} from '@/components/confirm-delete-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template';
import { Trash2 } from 'lucide-react';
import { PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
type CompilationTemplateDropdownProps = PropsWithChildren<{
data: ICompilationTemplateGroup;
onDelete: (id: string) => void;
}>;
export function CompilationTemplateDropdown({
children,
data,
onDelete,
}: CompilationTemplateDropdownProps) {
const { t } = useTranslation();
const handleDelete = useCallback(() => {
onDelete(data.id);
}, [data.id, onDelete]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<ConfirmDeleteDialog
title={t('setting.deleteTemplateGroupModalTitle')}
content={{
title: t('setting.deleteTemplateGroupModalContent'),
node: <ConfirmDeleteDialogNode name={data.name} />,
}}
onOk={handleDelete}
>
<DropdownMenuItem
className="text-state-error"
onSelect={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
{t('common.delete')}
<Trash2 className="size-4" />
</DropdownMenuItem>
</ConfirmDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,4 +1,5 @@
export enum FlowType {
Agent = 'agent',
Compiler = 'compiler',
Flow = 'flow',
}

View File

@@ -1,46 +1,55 @@
import { ButtonLoading } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { TagRenameId } from '@/constants/knowledge';
import { AgentCategory } from '@/constants/agent';
import { BrainCircuit, Route } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CreateAgentForm, CreateAgentFormProps } from './create-agent-form';
type CreateAgentDialogProps = CreateAgentFormProps;
type CreateAgentDialogProps = CreateAgentFormProps & {
canvasCategory?: AgentCategory;
};
export function CreateAgentDialog({
hideModal,
onOk,
loading,
shouldChooseAgent,
canvasCategory,
}: CreateAgentDialogProps) {
const { t } = useTranslation();
const dialogTitle =
canvasCategory === AgentCategory.DataflowCanvas
? t('flow.createIngestionPipeline')
: canvasCategory === AgentCategory.AgentCanvas
? t('flow.createWorkflow')
: t('common.create');
const DialogIcon =
canvasCategory === AgentCategory.DataflowCanvas
? Route
: canvasCategory === AgentCategory.AgentCanvas
? BrainCircuit
: undefined;
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent data-testid="agent-create-modal">
<DialogContent data-testid="agent-create-modal" className="max-w-[800px]">
<DialogHeader>
<DialogTitle>{t('flow.createGraph')}</DialogTitle>
<DialogTitle className="flex items-center gap-2">
{DialogIcon && <DialogIcon className="size-5" />}
{dialogTitle}
</DialogTitle>
</DialogHeader>
<CreateAgentForm
hideModal={hideModal}
onOk={onOk}
shouldChooseAgent={shouldChooseAgent}
loading={loading}
showTypeCards={!canvasCategory}
></CreateAgentForm>
<DialogFooter>
<ButtonLoading
data-testid="agent-save"
type="submit"
form={TagRenameId}
loading={loading}
>
{t('common.save')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,29 +1,47 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button, ButtonLoading } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DialogFooter } from '@/components/ui/dialog';
import { Form } from '@/components/ui/form';
import { TagRenameId } from '@/constants/knowledge';
import { IModalProps } from '@/interfaces/common';
import { cn } from '@/lib/utils';
import { BrainCircuit, Check, Route } from 'lucide-react';
import { Routes } from '@/routes';
import { BrainCircuit, Check, Route, Shapes } from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { FlowType } from './constant';
import { NameFormField, NameFormSchema } from './name-form-field';
export type CreateAgentFormProps = IModalProps<any> & {
shouldChooseAgent?: boolean;
loading?: boolean;
showTypeCards?: boolean;
};
type FlowTypeCardProps = {
value?: FlowType;
onChange?: (value: FlowType) => void;
};
const FLOW_TYPE_CONFIG: Record<
FlowType,
{ icon: typeof BrainCircuit; labelKey: string }
> = {
[FlowType.Flow]: { icon: Route, labelKey: 'tabList.ingestionPipeline' },
[FlowType.Compiler]: {
icon: Shapes,
labelKey: 'tabList.compilationOperator',
},
[FlowType.Agent]: { icon: BrainCircuit, labelKey: 'tabList.workflow' },
};
function FlowTypeCards({ value, onChange }: FlowTypeCardProps) {
const { t } = useTranslation();
const handleChange = useCallback(
@@ -35,8 +53,10 @@ function FlowTypeCards({ value, onChange }: FlowTypeCardProps) {
return (
<section className="flex gap-10">
{Object.values(FlowType).map((val) => {
{[FlowType.Flow, FlowType.Compiler, FlowType.Agent].map((val) => {
const isActive = value === val;
const config = FLOW_TYPE_CONFIG[val];
const Icon = config.icon;
return (
<Card
key={val}
@@ -55,16 +75,8 @@ function FlowTypeCards({ value, onChange }: FlowTypeCardProps) {
)}
>
<div className="flex gap-2">
{val === FlowType.Agent ? (
<BrainCircuit className="size-6" />
) : (
<Route className="size-6" />
)}
<p>
{t(
`flow.${val === FlowType.Agent ? 'createAgent' : 'createPipeline'}`,
)}
</p>
<Icon className="size-6" />
<p>{t(`flow.${config.labelKey}`)}</p>
</div>
{isActive && <Check />}
</CardContent>
@@ -87,15 +99,26 @@ export type FormSchemaType = z.infer<typeof FormSchema>;
export function CreateAgentForm({
hideModal,
onOk,
shouldChooseAgent = false,
loading,
showTypeCards = false,
}: CreateAgentFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const form = useForm<FormSchemaType>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '', type: FlowType.Agent },
});
const selectedType = useWatch({ control: form.control, name: 'type' });
// Compilation operators are configured on the edit-next page, so the dialog
// skips the name field and turns the submit button into a navigation step.
const isCompiler = showTypeCards && selectedType === FlowType.Compiler;
const handleNext = useCallback(() => {
navigate(`${Routes.CompilationTemplatesEditNext}?source=agents`);
}, [navigate]);
async function onSubmit(data: FormSchemaType) {
const ret = await onOk?.(data);
if (ret) {
@@ -110,7 +133,7 @@ export function CreateAgentForm({
className="space-y-6"
id={TagRenameId}
>
{shouldChooseAgent && (
{showTypeCards && (
<RAGFlowFormItem
required
name="type"
@@ -119,8 +142,27 @@ export function CreateAgentForm({
<FlowTypeCards></FlowTypeCards>
</RAGFlowFormItem>
)}
<NameFormField></NameFormField>
{!isCompiler && <NameFormField></NameFormField>}
</form>
<DialogFooter>
<Button type="button" variant="outline" onClick={hideModal}>
{t('common.cancel')}
</Button>
{isCompiler ? (
<Button type="button" data-testid="agent-next" onClick={handleNext}>
{t('common.next')}
</Button>
) : (
<ButtonLoading
data-testid="agent-save"
type="submit"
form={TagRenameId}
loading={loading}
>
{t('common.confirm')}
</ButtonLoading>
)}
</DialogFooter>
</Form>
);
}

View File

@@ -1,9 +1,11 @@
import { FilterCollection } from '@/components/list-filter-bar/interface';
import { AgentCategory } from '@/constants/agent';
import {
useFetchAgentList,
useFetchAgentTags,
} from '@/hooks/use-agent-request';
import { buildOwnersFilter, groupListByType } from '@/utils/list-filter-util';
import { AgentListItemType, IFlow } from '@/interfaces/database/agent';
import { buildOwnersFilter } from '@/utils/list-filter-util';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,11 +14,14 @@ export function useSelectFilters() {
const { data } = useFetchAgentList({});
const { data: tagCounts } = useFetchAgentTags();
const canvasCategory = useMemo(() => {
return groupListByType(
data?.canvas ?? [],
'canvas_category',
'canvas_category',
// The merged /agents list also contains compilation template groups, which
// have no owner fields — drop them before building the owner filter.
const agents = useMemo(() => {
const canvas = (data?.canvas ?? []) as Array<
IFlow & { type?: AgentListItemType }
>;
return canvas.filter(
(x) => x.type !== AgentListItemType.CompilationTemplateGroup,
);
}, [data?.canvas]);
@@ -31,10 +36,23 @@ export function useSelectFilters() {
);
const filters: FilterCollection[] = [
buildOwnersFilter(data?.canvas ?? [], undefined, t('common.owner')),
buildOwnersFilter(agents, undefined, t('common.owner')),
{
field: 'canvasCategory',
list: canvasCategory,
list: [
{
id: AgentCategory.DataflowCanvas,
label: t('flow.tabList.ingestionPipeline'),
},
{
id: AgentListItemType.CompilationTemplateGroup,
label: t('flow.tabList.compilationOperator'),
},
{
id: AgentCategory.AgentCanvas,
label: t('flow.tabList.workflow'),
},
],
label: t('flow.canvasCategory'),
},
{

View File

@@ -13,13 +13,15 @@ import {
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchAgentListByPage } from '@/hooks/use-agent-request';
import { useDeleteCompilationTemplateGroup } from '@/hooks/use-compilation-template-group-request';
import { Routes } from '@/routes';
import { t } from 'i18next';
import { pick } from 'lodash';
import { Clipboard, ClipboardPlus, FileInput, Plus } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import { AgentCard } from './agent-card';
import { CompilationTemplateCard } from './compilation-template-card';
import { CreateAgentDialog } from './create-agent-dialog';
import { useCreateAgentOrPipeline } from './hooks/use-create-agent';
import { useSelectFilters } from './hooks/use-select-filters';
@@ -27,7 +29,11 @@ import { UploadAgentDialog } from './upload-agent-dialog';
import { useHandleImportJsonFile } from './use-import-json';
import { useRenameAgent } from './use-rename-agent';
const CompilationGroupCategory = 'compilation_template_group';
export default function Agents() {
const { t } = useTranslation();
const {
data,
loading: listLoading,
@@ -39,7 +45,17 @@ export default function Agents() {
handleFilterSubmit,
} = useFetchAgentListByPage();
const canvasCategory = useMemo(
() =>
Array.isArray(filterValue.canvasCategory)
? (filterValue.canvasCategory[0] as string | undefined)
: undefined,
[filterValue.canvasCategory],
);
const isCompilation = canvasCategory === CompilationGroupCategory;
const { navigateToAgentTemplates } = useNavigatePage();
const navigate = useNavigate();
const {
agentRenameLoading,
@@ -65,6 +81,8 @@ export default function Agents() {
hideFileUploadModal,
} = useHandleImportJsonFile();
const { deleteGroup } = useDeleteCompilationTemplateGroup();
const filters = useSelectFilters();
const handlePageChange = useCallback(
@@ -73,6 +91,25 @@ export default function Agents() {
},
[setPagination],
);
const handleAddCompilation = useCallback(() => {
navigate(`${Routes.CompilationTemplatesEditNext}?source=agents`);
}, [navigate]);
const handleEditCompilation = useCallback(
(id: string) => () => {
navigate(`${Routes.CompilationTemplatesEditNext}/${id}?source=agents`);
},
[navigate],
);
const handleDeleteCompilation = useCallback(
async (id: string) => {
await deleteGroup(id);
},
[deleteGroup],
);
const [searchUrl, setSearchUrl] = useSearchParams();
const isCreate = searchUrl.get('isCreate') === 'true';
@@ -86,26 +123,32 @@ export default function Agents() {
return (
<>
{data?.length || searchString ? (
<article
className="size-full min-w-0 flex flex-col"
data-testid="agents-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
title={t('flow.agents')}
searchString={searchString}
onSearchChange={handleInputChange}
icon="agents"
filters={filters}
onChange={handleFilterSubmit}
value={filterValue}
>
<article
className="size-full min-w-0 flex flex-col"
data-testid="agents-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
title={t('flow.agents')}
icon="agents"
searchString={searchString}
onSearchChange={handleInputChange}
filters={filters}
onChange={handleFilterSubmit}
value={filterValue}
>
{isCompilation ? (
<Button
onClick={handleAddCompilation}
data-testid="create-compilation-template"
>
<Plus className="size-[1em]" />
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger data-testid="create-agent" asChild>
<Button>
<Plus className="size-[1em]" />
{t('flow.createGraph')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-testid="agent-create-menu">
@@ -133,93 +176,99 @@ export default function Agents() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ListFilterBar>
</header>
)}
</ListFilterBar>
</header>
{data.length ? (
<>
<CardContainer className="flex-1 overflow-auto px-5">
{data.map((x) => {
return (
{data.length ? (
<>
<CardContainer className="flex-1 overflow-auto px-5">
{isCompilation
? (data as any[]).map((item) => (
<CompilationTemplateCard
key={item.id}
data={item}
onClick={handleEditCompilation(item.id)}
onDelete={handleDeleteCompilation}
/>
))
: data.map((x) => (
<AgentCard
key={x.id}
data={x}
showAgentRenameModal={showAgentRenameModal}
/>
);
})}
</CardContainer>
))}
</CardContainer>
<footer className="mt-4 px-5 pb-5">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
/>
</footer>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
isSearch
type={EmptyCardType.Agent}
onClick={() => showCreatingModal()}
<footer className="mt-4 px-5 pb-5">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
/>
</div>
)}
</article>
) : listLoading ? (
<article className="size-full" data-testid="agents-list"></article>
) : (
<article
className="size-full flex items-center justify-center"
data-testid="agents-list"
>
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14 !cursor-default"
type={EmptyCardType.Agent}
tabIndex={-1}
// onClick={() => showCreatingModal()}
>
<ul className="flex flex-col gap-y-5 text-text-secondary text-sm pt-5">
<li data-testid="agents-empty-create">
<Button
variant="static"
size="auto"
onClick={showCreatingModal}
>
<Clipboard className="size-[1em]" />
{t('flow.createFromBlank')}
</Button>
</li>
</footer>
</>
) : searchString ? (
<div className="flex-1 flex items-center justify-center">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
isSearch
type={EmptyCardType.Agent}
onClick={() => showCreatingModal()}
/>
</div>
) : listLoading ? null : (
<div className="flex-1 flex items-center justify-center">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14 !cursor-default"
type={EmptyCardType.Agent}
tabIndex={-1}
// onClick={() => showCreatingModal()}
>
<ul className="flex flex-col gap-y-5 text-text-secondary text-sm pt-5">
<li data-testid="agents-empty-create">
<Button
variant="static"
size="auto"
onClick={showCreatingModal}
>
<Clipboard className="size-[1em]" />
{t('flow.createFromBlank')}
</Button>
</li>
<li>
<Button
asLink
variant="static"
size="auto"
to={Routes.AgentTemplates}
>
<ClipboardPlus className="size-[1em]" />
{t('flow.createFromTemplate')}
</Button>
</li>
<li>
<Button
asLink
variant="static"
size="auto"
to={Routes.AgentTemplates}
>
<ClipboardPlus className="size-[1em]" />
{t('flow.createFromTemplate')}
</Button>
</li>
<li>
<Button variant="static" size="auto" onClick={handleImportJson}>
<FileInput className="size-[1em]" />
{t('flow.importJsonFile')}
</Button>
</li>
</ul>
</EmptyAppCard>
</article>
)}
<li>
<Button
variant="static"
size="auto"
onClick={handleImportJson}
>
<FileInput className="size-[1em]" />
{t('flow.importJsonFile')}
</Button>
</li>
</ul>
</EmptyAppCard>
</div>
)}
</article>
{agentRenameVisible && (
<RenameDialog
@@ -234,7 +283,6 @@ export default function Agents() {
loading={loading}
visible={creatingVisible}
hideModal={hideCreatingModal}
shouldChooseAgent
onOk={handleCreateAgentOrPipeline}
></CreateAgentDialog>
)}

View File

@@ -5,6 +5,7 @@ import {
import { type IStructureGraphTemplate } from '@/interfaces/database/document-structure';
import { formatKindLabel } from '@/utils/compilation-template-util';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface RepresentationSelectProps {
templates: IStructureGraphTemplate[];
@@ -17,6 +18,7 @@ export function RepresentationSelect({
value,
onChange,
}: RepresentationSelectProps) {
const { t } = useTranslation();
const options = useMemo<SelectWithSearchFlagOptionType[]>(() => {
return templates.map((template) => ({
value: template.template_id,
@@ -24,13 +26,13 @@ export function RepresentationSelect({
<span className="flex items-center gap-2">
<span className="truncate">{template.template_name}</span>
<span className="text-xs text-text-secondary shrink-0">
{formatKindLabel(template.kind)}
{formatKindLabel(t, template.kind)}
</span>
</span>
),
keywords: [template.template_name, template.kind],
}));
}, [templates]);
}, [templates, t]);
return (
<SelectWithSearch

View File

@@ -0,0 +1,112 @@
import { type SelectWithSearchFlagOptionType } from '@/components/originui/select-with-search';
import { getEntityDisplayName } from '@/components/structure-graph/adapters';
import { type ClickableNode } from '@/components/structure-graph/representation-renderer';
import { CompilationTemplateKind } from '@/constants/compilation';
import { useFetchDocumentStructureGraph } from '@/hooks/use-document-request';
import { useCallback, useMemo, useState } from 'react';
import { useSelectedTemplate } from './use-selected-template';
export function useGraphEntitySearch(
onNodeClick?: (node: ClickableNode) => void,
) {
const [graphKeywords, setGraphKeywords] = useState('');
const [selectedNodeId, setSelectedNodeId] = useState(''); // entity name
const { data, loading } = useFetchDocumentStructureGraph(graphKeywords);
const templates = useMemo(() => data?.templates ?? [], [data?.templates]);
const {
selectedTemplateId,
setSelectedTemplateId,
selectedTemplate,
selectedKind,
} = useSelectedTemplate(templates);
const isGraphKind = selectedKind === CompilationTemplateKind.KnowledgeGraph;
const entityOptions = useMemo<SelectWithSearchFlagOptionType[]>(
() =>
(selectedTemplate?.entities ?? []).map((entity) => {
const name = getEntityDisplayName(entity);
return {
label: name,
value: name,
keywords: [name, ...(entity.aliases ?? [])],
};
}),
[selectedTemplate?.entities],
);
// Only refill the select when the selected entity is still in the current
// graph data, to avoid showing raw text with no matching option
const selectedEntityName =
selectedNodeId &&
(selectedTemplate?.entities ?? []).some(
(entity) => getEntityDisplayName(entity) === selectedNodeId,
)
? selectedNodeId
: '';
const handleSelectEntity = useCallback(
(name: string) => {
if (!name) {
setGraphKeywords('');
setSelectedNodeId('');
return;
}
setSelectedNodeId(name);
const entity = (selectedTemplate?.entities ?? []).find(
(item) => getEntityDisplayName(item) === name,
);
if (entity?.source_chunk_ids?.length) {
onNodeClick?.({
id: entity.id ?? name,
name,
source_chunk_ids: entity.source_chunk_ids,
});
}
},
[selectedTemplate?.entities, onNodeClick],
);
const handleNoMatchEnter = useCallback((keywords: string) => {
setGraphKeywords(keywords);
setSelectedNodeId('');
}, []);
// Two-way binding: clicking a graph node selects it in the dropdown,
// then forwards to chunk navigation like before
const handleNodeClick = useCallback(
(node: ClickableNode) => {
if (isGraphKind && node.name) {
setSelectedNodeId(node.name);
}
if (!node.source_chunk_ids?.length) return;
onNodeClick?.(node);
},
[isGraphKind, onNodeClick],
);
const handleTemplateChange = useCallback(
(templateId: string) => {
setSelectedTemplateId(templateId);
setGraphKeywords('');
setSelectedNodeId('');
},
[setSelectedTemplateId],
);
return {
data,
loading,
templates,
selectedTemplateId,
selectedTemplate,
isGraphKind,
entityOptions,
graphSelectValue: selectedEntityName || graphKeywords,
highlightNodeId: selectedEntityName || null,
handleSelectEntity,
handleNoMatchEnter,
handleTemplateChange,
handleNodeClick,
};
}

View File

@@ -1,19 +1,18 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { ExpandableSearchInput } from '@/components/expandable-search-input';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { SkeletonCard } from '@/components/skeleton-card';
import { Button } from '@/components/ui/button';
import {
useDeleteDocumentStructureGraph,
useFetchDocumentStructureGraph,
} from '@/hooks/use-document-request';
import { useDeleteDocumentStructureGraph } from '@/hooks/use-document-request';
import { Trash2 } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type ClickableNode,
RepresentationRenderer,
} from './components/representation-renderer';
} from '@/components/structure-graph/representation-renderer';
import { RepresentationSelect } from './components/representation-select';
import { useSelectedTemplate } from './hooks/use-selected-template';
import { useGraphEntitySearch } from './hooks/use-graph-entity-search';
interface RepresentationProps {
onNodeClick?: (node: ClickableNode) => void;
@@ -21,14 +20,26 @@ interface RepresentationProps {
function Representation({ onNodeClick }: RepresentationProps) {
const { t } = useTranslation();
const { data, loading } = useFetchDocumentStructureGraph();
const { deleteDocumentStructureGraph, loading: deleting } =
useDeleteDocumentStructureGraph();
const templates = data?.templates ?? [];
const { selectedTemplateId, setSelectedTemplateId, selectedTemplate } =
useSelectedTemplate(templates);
const [searchKeyword, setSearchKeyword] = useState('');
const {
data,
loading,
templates,
selectedTemplateId,
selectedTemplate,
isGraphKind,
entityOptions,
graphSelectValue,
highlightNodeId,
handleSelectEntity,
handleNoMatchEnter,
handleTemplateChange,
handleNodeClick,
} = useGraphEntitySearch(onNodeClick);
const handleSearchChange = useCallback((value: string) => {
setSearchKeyword(value);
}, []);
@@ -38,28 +49,32 @@ function Representation({ onNodeClick }: RepresentationProps) {
await deleteDocumentStructureGraph(selectedTemplateId);
}, [deleteDocumentStructureGraph, selectedTemplateId]);
const handleNodeClick = useCallback(
(node: ClickableNode) => {
if (!node.source_chunk_ids?.length) return;
onNodeClick?.(node);
},
[onNodeClick],
);
return (
<section className="p-5 rounded-2xl h-full flex flex-col">
<div className="flex items-center justify-between">
<RepresentationSelect
templates={templates}
value={selectedTemplateId}
onChange={setSelectedTemplateId}
onChange={handleTemplateChange}
/>
<div className="relative flex items-center gap-2">
<ExpandableSearchInput
value={searchKeyword}
onChange={handleSearchChange}
placeholder={t('chunk.search', 'Search')}
/>
{isGraphKind ? (
<SelectWithSearch
options={entityOptions}
value={graphSelectValue}
onChange={handleSelectEntity}
placeholder={t('knowledgeDetails.searchEntity')}
allowClear
onNoMatchEnter={handleNoMatchEnter}
disableAutoSelectOnEnter
/>
) : (
<ExpandableSearchInput
value={searchKeyword}
onChange={handleSearchChange}
placeholder={t('chunk.search', 'Search')}
/>
)}
{templates.length > 0 && (
<ConfirmDeleteDialog onOk={handleDelete}>
<Button
@@ -76,12 +91,8 @@ function Representation({ onNodeClick }: RepresentationProps) {
)}
</div>
</div>
{loading && (
<div className="mt-6 text-text-secondary">
{t('common.loading', 'Loading...')}
</div>
)}
{!loading && templates.length === 0 && (
{loading && !data && <SkeletonCard className="mt-6" />}
{!(loading && !data) && templates.length === 0 && (
<div className="mt-6 text-text-secondary">
{t(
'chunk.representationEmpty',
@@ -89,11 +100,12 @@ function Representation({ onNodeClick }: RepresentationProps) {
)}
</div>
)}
{!loading && templates.length > 0 && (
{!(loading && !data) && templates.length > 0 && (
<RepresentationRenderer
template={selectedTemplate}
searchKeyword={searchKeyword}
onNodeClick={handleNodeClick}
highlightNodeId={highlightNodeId}
/>
)}
</section>

View File

@@ -1,10 +1,40 @@
import { GenerateType } from '@/pages/dataset/dataset/generate-button/constants';
export enum ViewMode {
LlmWiki = 'llm-wiki',
Skills = 'skills',
Tree = 'tree',
Graph = 'graph',
MindMap = 'mindmap',
Timeline = 'timeline',
// SessionEssence = 'session_essence',
// SessionGraph = 'session_graph',
}
export enum LeftPanelTab {
Contents = 'contents',
Graph = 'graph',
}
export const StructureKinds = [
ViewMode.Graph,
ViewMode.MindMap,
ViewMode.Timeline,
// ViewMode.SessionEssence,
// ViewMode.SessionGraph,
] as const;
export type StructureKind = (typeof StructureKinds)[number];
export type GenerableViewMode = Exclude<ViewMode, ViewMode.Tree>;
export const ViewModeGenerateTypeMap: Record<GenerableViewMode, GenerateType> =
{
[ViewMode.LlmWiki]: GenerateType.Artifact,
[ViewMode.Skills]: GenerateType.ToSkills,
[ViewMode.Graph]: GenerateType.KnowledgeGraph,
[ViewMode.MindMap]: GenerateType.MindMap,
[ViewMode.Timeline]: GenerateType.Timeline,
// [ViewMode.SessionEssence]: GenerateType.SessionEssence,
// [ViewMode.SessionGraph]: GenerateType.SessionGraph,
};

View File

@@ -0,0 +1,127 @@
import {
SelectWithSearch,
SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import { getEntityDisplayName } from '@/components/structure-graph/adapters';
import { RepresentationRenderer } from '@/components/structure-graph/representation-renderer';
import { Card } from '@/components/ui/card';
import {
DatasetStructureKeys,
useFetchDatasetStructureGraph,
useFetchKnowledgeBaseConfiguration,
useKnowledgeBaseId,
} from '@/hooks/use-knowledge-request';
import { GenerateStatus } from '@/pages/dataset/dataset/generate-button/constants';
import { useTraceRunData } from '@/pages/dataset/dataset/generate-button/hook';
import { useGenerateStatus } from '@/pages/dataset/dataset/generate-button/use-generate-status';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StructureKind, ViewMode, ViewModeGenerateTypeMap } from './constants';
import CompilationEmptyState from './empty-state';
import { CompilationLoadingCard } from './loading-card';
interface DatasetStructureViewProps {
kind: StructureKind;
}
export function DatasetStructureView({ kind }: DatasetStructureViewProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const knowledgeBaseId = useKnowledgeBaseId();
const { data: knowledgeBase } = useFetchKnowledgeBaseConfiguration();
const [graphKeywords, setGraphKeywords] = useState('');
const [selectedNodeId, setSelectedNodeId] = useState('');
const { data, loading } = useFetchDatasetStructureGraph(kind, graphKeywords);
const template = data?.templates?.[0];
const { data: structureRunData } = useTraceRunData(
ViewModeGenerateTypeMap[kind],
);
const { status: structureStatus } = useGenerateStatus(structureRunData);
useEffect(() => {
if (structureStatus === GenerateStatus.completed) {
queryClient.invalidateQueries({
queryKey: DatasetStructureKeys.kind(knowledgeBaseId, kind),
});
}
}, [structureStatus, queryClient, knowledgeBaseId, kind]);
const entityOptions = useMemo<SelectWithSearchFlagOptionType[]>(
() =>
(template?.entities ?? []).map((entity) => {
const name = getEntityDisplayName(entity);
return {
label: name,
value: name,
keywords: [name, ...(entity.aliases ?? [])],
};
}),
[template?.entities],
);
// Only refill the select when the selected entity is still in the current
// graph data, to avoid showing raw text with no matching option
const selectedEntityName =
selectedNodeId &&
(template?.entities ?? []).some(
(entity) => getEntityDisplayName(entity) === selectedNodeId,
)
? selectedNodeId
: '';
const handleSelectEntity = useCallback((name: string) => {
if (!name) {
setGraphKeywords('');
setSelectedNodeId('');
return;
}
setSelectedNodeId(name);
}, []);
const handleNoMatchEnter = useCallback((keywords: string) => {
setGraphKeywords(keywords);
setSelectedNodeId('');
}, []);
const canGenerate = (knowledgeBase?.chunk_count ?? 0) > 0;
if (loading && !data) {
return <CompilationLoadingCard />;
}
if (!template && !graphKeywords) {
return (
<CompilationEmptyState
type={kind}
disabled={!canGenerate}
data={structureRunData}
/>
);
}
return (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
{kind === ViewMode.Graph && (
<div className="flex justify-end px-4 pt-4">
<SelectWithSearch
options={entityOptions}
value={selectedEntityName || graphKeywords}
onChange={handleSelectEntity}
placeholder={t('knowledgeDetails.searchEntity')}
allowClear
triggerClassName="w-96 max-w-full"
onNoMatchEnter={handleNoMatchEnter}
disableAutoSelectOnEnter
/>
</div>
)}
<RepresentationRenderer
template={template}
highlightNodeId={selectedEntityName || null}
/>
</Card>
);
}

View File

@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import { IconFontFill } from '@/components/icon-font';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GenerateType } from '@/pages/dataset/dataset/generate-button/constants';
import {
ITraceInfo,
useDatasetGenerate,
@@ -14,7 +13,13 @@ import { useGenerateStatus } from '@/pages/dataset/dataset/generate-button/use-g
import { replaceText } from '@/pages/dataset/process-log-modal';
import { toFixed } from '@/utils/common-util';
type EmptyStateType = 'llm-wiki' | 'skills';
import {
GenerableViewMode,
ViewMode,
ViewModeGenerateTypeMap,
} from './constants';
type EmptyStateType = GenerableViewMode;
interface ICompilationEmptyStateProps {
type: EmptyStateType;
@@ -22,19 +27,24 @@ interface ICompilationEmptyStateProps {
data?: ITraceInfo;
}
const DefaultGenerateTypeMap: Record<EmptyStateType, GenerateType> = {
'llm-wiki': GenerateType.Artifact,
skills: GenerateType.ToSkills,
};
const TitleKeyMap: Record<EmptyStateType, string> = {
'llm-wiki': 'knowledgeDetails.noWikiPages',
skills: 'knowledgeDetails.noSkills',
[ViewMode.LlmWiki]: 'knowledgeDetails.noWikiPages',
[ViewMode.Skills]: 'knowledgeDetails.noSkills',
[ViewMode.Graph]: 'knowledgeDetails.noStructureGraph',
[ViewMode.MindMap]: 'knowledgeDetails.noStructureMindmap',
[ViewMode.Timeline]: 'knowledgeDetails.noStructureTimeline',
// [ViewMode.SessionEssence]: 'knowledgeDetails.noStructureSessionEssence',
// [ViewMode.SessionGraph]: 'knowledgeDetails.noStructureSessionGraph',
};
const LabelKeyMap: Record<EmptyStateType, string> = {
'llm-wiki': 'knowledgeDetails.artifact',
skills: 'knowledgeDetails.toSkills',
[ViewMode.LlmWiki]: 'knowledgeDetails.artifact',
[ViewMode.Skills]: 'knowledgeDetails.toSkills',
[ViewMode.Graph]: 'knowledgeDetails.structureGraph',
[ViewMode.MindMap]: 'knowledgeDetails.structureMindmap',
[ViewMode.Timeline]: 'knowledgeDetails.structureTimeline',
// [ViewMode.SessionEssence]: 'knowledgeDetails.structureSessionEssence',
// [ViewMode.SessionGraph]: 'knowledgeDetails.structureSessionGraph',
};
export function CompilationEmptyState({
@@ -43,7 +53,7 @@ export function CompilationEmptyState({
data,
}: ICompilationEmptyStateProps) {
const { t } = useTranslation();
const generateType = DefaultGenerateTypeMap[type];
const generateType = ViewModeGenerateTypeMap[type];
const { runGenerate, pauseGenerate } = useDatasetGenerate();
const { status, percent } = useGenerateStatus(data);

View File

@@ -1,13 +1,17 @@
import BackButton from '@/components/back-button';
import {
SelectWithSearch,
type SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Button } from '@/components/ui/button';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { ViewMode } from './constants';
import { StructureKinds, ViewMode } from './constants';
import { DatasetStructureView } from './dataset-structure-view';
import { LlmWikiView } from './llm-wiki-view';
import { NavTreeView } from './nav-tree-view';
import { SkillsView } from './skills-view';
@@ -19,17 +23,48 @@ export default function Compilation() {
const { data: knowledgeBase } = useFetchKnowledgeBaseConfiguration();
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.LlmWiki);
const handleSwitchToLlmWiki = useCallback(() => {
setViewMode(ViewMode.LlmWiki);
const viewOptions = useMemo<SelectWithSearchFlagOptionType[]>(() => {
return [
{
value: ViewMode.LlmWiki,
label: t('knowledgeDetails.llmWiki'),
},
{
value: ViewMode.Skills,
label: t('knowledgeDetails.skills', 'To Skills'),
},
{
value: ViewMode.Tree,
label: t('knowledgeDetails.navTree'),
},
{
value: ViewMode.Graph,
label: t('knowledgeDetails.structureGraph'),
},
{
value: ViewMode.MindMap,
label: t('knowledgeDetails.structureMindmap'),
},
{
value: ViewMode.Timeline,
label: t('knowledgeDetails.structureTimeline'),
},
// {
// value: ViewMode.SessionEssence,
// label: t('knowledgeDetails.structureSessionEssence'),
// },
// {
// value: ViewMode.SessionGraph,
// label: t('knowledgeDetails.structureSessionGraph'),
// },
];
}, [t]);
const handleViewModeChange = useCallback((value: string) => {
setViewMode(value as ViewMode);
}, []);
const handleSwitchToSkills = useCallback(() => {
setViewMode(ViewMode.Skills);
}, []);
const handleSwitchToTree = useCallback(() => {
setViewMode(ViewMode.Tree);
}, []);
const structureKind = StructureKinds.find((kind) => kind === viewMode);
return (
<section className="flex flex-col p-4 gap-4 h-full">
@@ -51,37 +86,19 @@ export default function Compilation() {
</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === ViewMode.LlmWiki ? 'default' : 'outline'}
size="sm"
onClick={handleSwitchToLlmWiki}
>
{t('knowledgeDetails.llmWiki')}
</Button>
<Button
variant={viewMode === ViewMode.Skills ? 'default' : 'outline'}
size="sm"
onClick={handleSwitchToSkills}
>
To Skills
</Button>
<Button
variant={viewMode === ViewMode.Tree ? 'default' : 'outline'}
size="sm"
onClick={handleSwitchToTree}
>
{t('knowledgeDetails.navTree')}
</Button>
</div>
<SelectWithSearch
options={viewOptions}
value={viewMode}
onChange={handleViewModeChange}
triggerClassName="w-96"
/>
</section>
</header>
{viewMode === ViewMode.LlmWiki && <LlmWikiView />}
{viewMode === ViewMode.Skills && <SkillsView />}
{viewMode === ViewMode.Tree && <NavTreeView />}
{structureKind && <DatasetStructureView kind={structureKind} />}
</section>
);
}

View File

@@ -20,7 +20,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { LeftPanelTab } from './constants';
import { LeftPanelTab, ViewMode } from './constants';
import CompilationEmptyState from './empty-state';
import { useCompilationArtifact } from './hooks/use-compilation-artifact';
import { CompilationLoadingCard } from './loading-card';
@@ -43,9 +43,11 @@ export function LlmWikiView() {
const { data: artifactRunData } = useTraceRunData(GenerateType.Artifact);
const { status: artifactStatus } = useGenerateStatus(artifactRunData);
const [updateSheetOpen, setUpdateSheetOpen] = useState(false);
useEffect(() => {
if (artifactStatus === GenerateStatus.completed) {
setUpdateSheetOpen(false);
queryClient.invalidateQueries({
queryKey: ArtifactKeys.listByDataset(id!),
});
@@ -70,7 +72,7 @@ export function LlmWikiView() {
if (isEmpty) {
return (
<CompilationEmptyState
type="llm-wiki"
type={ViewMode.LlmWiki}
disabled={!canGenerate}
data={artifactRunData}
/>
@@ -88,6 +90,9 @@ export function LlmWikiView() {
onSelectArtifact={handleSelectArtifact}
onClearArtifact={clearSelectedArtifact}
onClearWiki={clearSelectedArtifact}
updateSheetOpen={updateSheetOpen}
onUpdateSheetOpenChange={setUpdateSheetOpen}
traceData={artifactRunData}
/>
</ResizablePanel>
<ResizableHandle withHandle />

View File

@@ -17,6 +17,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { ViewMode } from './constants';
import CompilationEmptyState from './empty-state';
import { useCompilationSkill } from './hooks/use-compilation-skill';
import { CompilationLoadingCard } from './loading-card';
@@ -56,7 +57,7 @@ export function SkillsView() {
if (isEmpty) {
return (
<CompilationEmptyState
type="skills"
type={ViewMode.Skills}
disabled={!canGenerate}
data={skillRunData}
/>

View File

@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import {
useFetchArtifactAlteration,
useRunArtifactIndex,
} from '@/hooks/use-knowledge-request';
type UseWikiUpdateOptions = {
onUpdate?: () => void;
};
export function useWikiUpdate({ onUpdate }: UseWikiUpdateOptions = {}) {
const { data, loading: queryLoading } = useFetchArtifactAlteration();
const { runArtifactIndex, loading: mutationLoading } = useRunArtifactIndex();
const newlyUploaded = data?.newly_uploaded ?? 0;
const removed = data?.removed ?? 0;
const hasChanges = newlyUploaded > 0 || removed > 0;
const handleUpdate = useCallback(async () => {
const result = await runArtifactIndex();
if (result?.code === 0) {
onUpdate?.();
}
}, [runArtifactIndex, onUpdate]);
return {
hasChanges,
newlyUploaded,
removed,
handleUpdate,
loading: queryLoading || mutationLoading,
};
}

View File

@@ -1,26 +1,25 @@
import ArtifactForceGraph from '@/components/artifact-force-graph';
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import {
SelectWithSearch,
SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useFetchArtifactGraph } from '@/hooks/use-knowledge-request';
import { IArtifact, IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { IArtifact } from '@/interfaces/database/dataset';
import { ITraceInfo } from '@/pages/dataset/dataset/generate-button/hook';
import { Trash2, WandSparkles } from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { LeftPanelTab } from '../constants';
import { useWikiClear } from './hooks/use-wiki-clear';
import { useWikiUpdate } from './hooks/use-wiki-update';
import { WikiGraphPanel } from './wiki-graph-panel';
import { WikiNavBar } from './wiki-nav-bar';
const mapNodeToValue = (node: IArtifactGraphEntity) => ({
slug: node.slug,
title: node.name,
page_type: node.type,
});
import { WikiUpdateSheet } from './wiki-update-sheet';
type WikiLeftPanelProps = {
tab: LeftPanelTab;
@@ -29,6 +28,9 @@ type WikiLeftPanelProps = {
onSelectArtifact: (artifact: IArtifact) => void;
onClearArtifact: () => void;
onClearWiki?: () => void;
updateSheetOpen: boolean;
onUpdateSheetOpenChange: (open: boolean) => void;
traceData?: ITraceInfo;
};
export function WikiLeftPanel({
@@ -38,59 +40,66 @@ export function WikiLeftPanel({
onSelectArtifact,
onClearArtifact,
onClearWiki,
updateSheetOpen,
onUpdateSheetOpenChange,
traceData,
}: WikiLeftPanelProps) {
const { t } = useTranslation();
const { data } = useFetchArtifactGraph(undefined, {
enabled: tab === LeftPanelTab.Graph,
});
const { open, setOpen, handleConfirm, loading } = useWikiClear({
onClearWiki,
});
const entityOptions = useMemo<SelectWithSearchFlagOptionType[]>(
() =>
data.entities.map((entity) => ({
label: entity.name,
value: entity.slug,
keywords: [entity.name, ...entity.aliases],
})),
[data.entities],
);
const {
hasChanges,
newlyUploaded,
removed,
handleUpdate,
loading: updateLoading,
} = useWikiUpdate();
// Only refill the select when selectedArtifact is a graph entity, to avoid showing the raw slug
const selectedEntitySlug = data.entities.some(
(entity) => entity.slug === selectedArtifact?.slug,
)
? (selectedArtifact?.slug ?? '')
: '';
const handleSelectEntity = useCallback(
(slug: string) => {
if (!slug) {
onClearArtifact();
return;
}
const entity = data.entities.find((item) => item.slug === slug);
if (entity) {
onSelectArtifact(mapNodeToValue(entity));
}
},
[data.entities, onSelectArtifact, onClearArtifact],
);
const handleUpdateClick = useCallback(async () => {
onUpdateSheetOpenChange(true);
await handleUpdate();
}, [handleUpdate, onUpdateSheetOpenChange]);
return (
<aside className="size-full flex flex-col p-5">
<section className="flex items-center justify-between pb-5">
<Tabs value={tab} onValueChange={onTabChange}>
<TabsList className="grid grid-cols-2 w-80">
<TabsTrigger value={LeftPanelTab.Contents}>
{t('knowledgeDetails.contents')}
</TabsTrigger>
<TabsTrigger value={LeftPanelTab.Graph}>
{t('knowledgeDetails.graph')}
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center justify-between pb-5">
{hasChanges && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'outline'}
onClick={handleUpdateClick}
disabled={updateLoading}
>
{t('knowledgeDetails.update', { defaultValue: 'Update' })}
{newlyUploaded > 0 && (
<Badge variant="success" className="ml-1">
{newlyUploaded}
</Badge>
)}
{removed > 0 && (
<Badge variant="destructive" className="ml-1">
{removed}
</Badge>
)}
<WandSparkles />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('knowledgeDetails.updateTooltip', {
newlyUploaded,
removed,
defaultValue:
'{{newlyUploaded}} new, {{removed}} removed documents found. Click to compile and merge into current Wiki.',
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<ConfirmDeleteDialog
open={open}
onOpenChange={setOpen}
@@ -107,7 +116,17 @@ export function WikiLeftPanel({
<Trash2 className="size-[1em]" />
</Button>
</ConfirmDeleteDialog>
</section>
</div>
<Tabs value={tab} onValueChange={onTabChange} className="pb-5">
<TabsList className="grid grid-cols-2 w-80">
<TabsTrigger value={LeftPanelTab.Contents}>
{t('knowledgeDetails.contents')}
</TabsTrigger>
<TabsTrigger value={LeftPanelTab.Graph}>
{t('knowledgeDetails.graph')}
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex-1 min-h-0 relative">
{tab === LeftPanelTab.Contents && (
@@ -117,25 +136,19 @@ export function WikiLeftPanel({
/>
)}
{tab === LeftPanelTab.Graph && (
<div className="flex h-full flex-col gap-3">
<SelectWithSearch
options={entityOptions}
value={selectedEntitySlug}
onChange={handleSelectEntity}
placeholder={t('knowledgeDetails.searchEntity')}
allowClear
triggerClassName="w-96 max-w-full"
/>
<ArtifactForceGraph
data={data}
show
mapNodeToValue={mapNodeToValue}
onNodeClick={onSelectArtifact}
highlightNodeId={selectedArtifact?.slug}
/>
</div>
<WikiGraphPanel
selectedArtifact={selectedArtifact}
onSelectArtifact={onSelectArtifact}
onClearArtifact={onClearArtifact}
/>
)}
</div>
<WikiUpdateSheet
open={updateSheetOpen}
onOpenChange={onUpdateSheetOpenChange}
data={traceData}
/>
</aside>
);
}

View File

@@ -0,0 +1,100 @@
import ArtifactForceGraph from '@/components/artifact-force-graph';
import {
SelectWithSearch,
SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import { useFetchArtifactGraph } from '@/hooks/use-knowledge-request';
import { IArtifact, IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { IFetchArtifactGraphRequestParams } from '@/interfaces/request/knowledge';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
const GraphSearchTopN = 12;
const mapNodeToValue = (node: IArtifactGraphEntity) => ({
slug: node.slug,
title: node.name,
page_type: node.type,
});
type WikiGraphPanelProps = {
selectedArtifact: IArtifact | null;
onSelectArtifact: (artifact: IArtifact) => void;
onClearArtifact: () => void;
};
export function WikiGraphPanel({
selectedArtifact,
onSelectArtifact,
onClearArtifact,
}: WikiGraphPanelProps) {
const { t } = useTranslation();
const [graphKeywords, setGraphKeywords] = useState('');
const graphParams = useMemo<IFetchArtifactGraphRequestParams | undefined>(
() =>
graphKeywords
? { keywords: graphKeywords, top_n: GraphSearchTopN }
: undefined,
[graphKeywords],
);
const { data } = useFetchArtifactGraph(graphParams);
const entityOptions = useMemo<SelectWithSearchFlagOptionType[]>(
() =>
data.entities.map((entity) => ({
label: entity.name,
value: entity.slug,
keywords: [entity.name, ...entity.aliases],
})),
[data.entities],
);
// Only refill the select when selectedArtifact is a graph entity, to avoid showing the raw slug
const selectedEntitySlug = data.entities.some(
(entity) => entity.slug === selectedArtifact?.slug,
)
? (selectedArtifact?.slug ?? '')
: '';
const handleSelectEntity = useCallback(
(slug: string) => {
if (!slug) {
setGraphKeywords('');
onClearArtifact();
return;
}
const entity = data.entities.find((item) => item.slug === slug);
if (entity) {
onSelectArtifact(mapNodeToValue(entity));
}
},
[data.entities, onSelectArtifact, onClearArtifact],
);
const handleNoMatchEnter = useCallback((keywords: string) => {
setGraphKeywords(keywords);
}, []);
return (
<div className="flex h-full flex-col gap-3">
<SelectWithSearch
options={entityOptions}
value={selectedEntitySlug || graphKeywords}
onChange={handleSelectEntity}
placeholder={t('knowledgeDetails.searchEntity')}
allowClear
triggerClassName="w-96 max-w-full"
onNoMatchEnter={handleNoMatchEnter}
disableAutoSelectOnEnter
/>
<ArtifactForceGraph
data={data}
show
mapNodeToValue={mapNodeToValue}
onNodeClick={onSelectArtifact}
highlightNodeId={selectedArtifact?.slug}
/>
</div>
);
}

View File

@@ -51,7 +51,7 @@ export function WikiNavBar({
} = useCreateDirectory();
return (
<div className="size-full flex flex-col gap-3 px-3">
<div className="size-full flex flex-col gap-3">
<SearchInput
placeholder={t('common.search')}
value={searchString}
@@ -91,6 +91,7 @@ export function WikiNavBar({
variant="secondary"
size="icon-xs"
onClick={handleShowCreateDialog}
className="hidden"
>
<Plus className="size-4" />
</Button>

View File

@@ -0,0 +1,101 @@
import { CirclePause, Logs } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { GenerateType } from '@/pages/dataset/dataset/generate-button/constants';
import {
ITraceInfo,
useDatasetGenerate,
} from '@/pages/dataset/dataset/generate-button/hook';
import { useGenerateStatus } from '@/pages/dataset/dataset/generate-button/use-generate-status';
import { replaceText } from '@/pages/dataset/process-log-modal';
import { toFixed } from '@/utils/common-util';
type WikiUpdateSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
data?: ITraceInfo;
};
export function WikiUpdateSheet({
open,
onOpenChange,
data,
}: WikiUpdateSheetProps) {
const { t } = useTranslation();
const { pauseGenerate } = useDatasetGenerate();
const { status, percent } = useGenerateStatus(data);
useEffect(() => {
if (status === 'completed') {
onOpenChange(false);
}
}, [status, onOpenChange]);
const handlePause = useCallback(() => {
if (data?.id) {
pauseGenerate({ task_id: data.id, type: GenerateType.Artifact }).catch(
() => {},
);
}
}, [pauseGenerate, data?.id]);
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<SheetContent className="flex flex-col">
<SheetHeader>
<SheetTitle>
{t('knowledgeDetails.updateSheetTitle', {
defaultValue: 'Update Wiki',
})}
</SheetTitle>
</SheetHeader>
<div className="flex-1 min-h-0 flex flex-col gap-4 pt-4">
<div className="flex items-center gap-2 text-text-primary">
<Logs className="size-5" />
<span>
{t('knowledgeDetails.artifact', { defaultValue: 'Artifact' })}
</span>
</div>
<div className="flex items-center gap-2">
<div
className={cn('bg-border-button h-1 rounded-full', {
'w-[calc(100%-100px)]': status === 'running',
'w-[calc(100%-50px)]': status === 'failed',
})}
>
<div
className={cn('h-1 rounded-full', {
'bg-state-error': status === 'failed',
'bg-accent-primary': status === 'running',
})}
style={{ width: `${toFixed(percent)}%` }}
/>
</div>
{status === 'running' && (
<span>{(toFixed(percent) as string) + '%'}</span>
)}
{status !== 'failed' && (
<span
className="text-state-error cursor-pointer"
onClick={handlePause}
>
<CirclePause />
</span>
)}
</div>
<div className="flex-1 whitespace-pre-line text-wrap rounded-lg overflow-y-auto scrollbar-auto p-2 bg-bg-base text-sm text-text-secondary">
{replaceText(data?.progress_msg || '')}
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -8,6 +8,10 @@ export enum ProcessingType {
raptor = 'RAPTOR',
artifact = 'Artifact',
skill = 'Skill',
mindmap = 'Mindmap',
timeline = 'Timeline',
sessionEssence = 'Session_Essence',
sessionGraph = 'Session_Graph',
}
export const ProcessingTypeMap = {
@@ -15,5 +19,9 @@ export const ProcessingTypeMap = {
[ProcessingType.raptor]: 'RAPTOR',
[ProcessingType.artifact]: 'Artifact',
[ProcessingType.skill]: 'Skill',
[ProcessingType.mindmap]: 'Mind Map',
[ProcessingType.timeline]: 'Timeline',
[ProcessingType.sessionEssence]: 'Session Essence',
[ProcessingType.sessionGraph]: 'Session Graph',
GraphRAG: 'Knowledge Graph',
};

View File

@@ -12,6 +12,10 @@ export enum GenerateType {
Raptor = 'Raptor',
Artifact = 'Artifact',
ToSkills = 'ToSkills',
MindMap = 'MindMap',
Timeline = 'Timeline',
SessionEssence = 'SessionEssence',
SessionGraph = 'SessionGraph',
}
export enum TraceType {
@@ -19,6 +23,10 @@ export enum TraceType {
Raptor = 'raptor',
Artifact = 'artifact',
Skill = 'skill',
MindMap = 'mindmap',
Timeline = 'timeline',
SessionEssence = 'session_essence',
SessionGraph = 'session_graph',
}
export const GenerateTypeMap = {
@@ -26,4 +34,8 @@ export const GenerateTypeMap = {
[GenerateType.Raptor]: ProcessingType.raptor,
[GenerateType.Artifact]: ProcessingType.artifact,
[GenerateType.ToSkills]: ProcessingType.skill,
[GenerateType.MindMap]: ProcessingType.mindmap,
[GenerateType.Timeline]: ProcessingType.timeline,
[GenerateType.SessionEssence]: ProcessingType.sessionEssence,
[GenerateType.SessionGraph]: ProcessingType.sessionGraph,
};

View File

@@ -18,7 +18,7 @@ enum DatasetKey {
const PollIntervalMs = 5000;
const DatasetGenerateKeys = {
export const DatasetGenerateKeys = {
trace: (type: GenerateType, id?: string, open?: boolean) =>
[type, id, open] as const,
traceById: (type: GenerateType, id?: string) => [type, id] as const,
@@ -74,6 +74,10 @@ const TraceTypeMap: Record<GenerateType, TraceType> = {
[GenerateType.Raptor]: TraceType.Raptor,
[GenerateType.Artifact]: TraceType.Artifact,
[GenerateType.ToSkills]: TraceType.Skill,
[GenerateType.MindMap]: TraceType.MindMap,
[GenerateType.Timeline]: TraceType.Timeline,
[GenerateType.SessionEssence]: TraceType.SessionEssence,
[GenerateType.SessionGraph]: TraceType.SessionGraph,
};
export const useTraceRunData = (type: GenerateType) => {

View File

@@ -35,7 +35,7 @@ import {
import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import pipe from 'lodash/fp/pipe';
import reactStringReplace from 'react-string-replace';
// Defining Tailwind CSS class name constants

View File

@@ -91,7 +91,7 @@ function TemplateSidebarItem({
</span>
{template?.kind && (
<span className="ml-2 shrink-0 text-text-secondary">
{formatKindLabel(template.kind)}
{formatKindLabel(t, template.kind)}
</span>
)}
</div>

View File

@@ -8,6 +8,7 @@ import { useFetchBuiltinCompilationTemplates } from '@/hooks/use-compilation-tem
import { useFetchDefaultModelDictionary } from '@/hooks/use-llm-request';
import { isCreateCompilationTemplateGroup } from '@/utils/compilation-template-util';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { formatKindLabel } from '@/utils/compilation-template-util';
@@ -18,6 +19,7 @@ import { useCompilationTemplateGroupSubmit } from '@/pages/user-setting/compilat
export const useCreateNextCompilationTemplateGroup = () => {
const { id } = useParams<{ id: string }>();
const { navigateToCompilationTemplates } = useNavigatePage();
const { t } = useTranslation();
const isCreate = isCreateCompilationTemplateGroup(id);
@@ -35,9 +37,9 @@ export const useCreateNextCompilationTemplateGroup = () => {
() =>
builtinKindOptions.map((option) => ({
...option,
label: formatKindLabel(option.value),
label: formatKindLabel(t, option.value),
})),
[builtinKindOptions],
[builtinKindOptions, t],
);
const { form } = useCompilationTemplateGroupForm({

View File

@@ -0,0 +1,118 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { Modal } from '@/components/ui/modal/modal';
import { Textarea } from '@/components/ui/textarea';
import { ICompilationTemplateSection } from '@/interfaces/database/compilation-template';
import { startCase } from 'lodash';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FieldLabelKeyMap } from '../utils';
import { useAddFieldForm } from '../hooks/use-add-field-form';
type AddFieldModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sectionName: string;
builtinSection?: ICompilationTemplateSection;
initialField?: Record<string, string>;
onAdd: (field: Record<string, string>) => void;
};
export function AddFieldModal({
open,
onOpenChange,
sectionName,
builtinSection,
initialField,
onAdd,
}: AddFieldModalProps) {
const { t } = useTranslation();
const {
form,
fieldKeys,
hasTypeField,
typeOptions,
handleTypeChange,
handleSubmit,
} = useAddFieldForm({
open,
builtinSection,
initialField,
});
const nonTypeKeys = fieldKeys.filter((key) => key !== 'type');
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const handleConfirm = useCallback(
(field: Record<string, string>) => {
onAdd(field);
onOpenChange(false);
},
[onAdd, onOpenChange],
);
const getFieldLabel = useCallback(
(key: string) => {
return FieldLabelKeyMap[key] ? t(FieldLabelKeyMap[key]) : startCase(key);
},
[t],
);
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={`${initialField ? t('setting.editFieldModalTitle') : t('setting.addFieldModalTitle')} - ${startCase(sectionName)}`}
size="default"
footer={
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
{t('common.cancel')}
</Button>
<Button type="button" onClick={handleSubmit(handleConfirm)}>
{t('common.confirm')}
</Button>
</div>
}
>
<Form {...form}>
<div className="space-y-4">
{hasTypeField && (
<RAGFlowFormItem name="type" label={getFieldLabel('type')}>
{(field) => (
<SelectWithSearch
{...field}
options={typeOptions}
allowClear
onChange={(value) => {
field.onChange(value);
handleTypeChange(value);
}}
placeholder={t('setting.selectFieldType')}
allowCustomValue
/>
)}
</RAGFlowFormItem>
)}
{nonTypeKeys.map((key) => (
<RAGFlowFormItem key={key} name={key} label={getFieldLabel(key)}>
<Textarea
placeholder={t('setting.descriptionPlaceholder')}
rows={key === 'description' ? 4 : 10}
resize="vertical"
/>
</RAGFlowFormItem>
))}
</div>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,72 @@
import { Collapse } from '@/components/collapse';
import MarkdownEditor from '@/components/markdown-editor';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Textarea } from '@/components/ui/textarea';
import { useFetchWikiPresets } from '@/hooks/use-compilation-template-request';
import { UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBlueprintSelection } from '../hooks/use-blueprint-selection';
import { FormSchemaType } from '../schema';
type BlueprintSectionProps = {
form: UseFormReturn<FormSchemaType>;
selectedTemplateIndex: number;
};
export function BlueprintSection({
form,
selectedTemplateIndex,
}: BlueprintSectionProps) {
const { t } = useTranslation();
const { data: presets } = useFetchWikiPresets();
const {
selectedValue,
options,
handleSelect,
instructionPath,
pageExample,
handlePageExampleChange,
} = useBlueprintSelection({ form, selectedTemplateIndex, presets });
if (presets.length === 0) {
return null;
}
return (
<section className="space-y-4 pt-4">
<Collapse
defaultOpen
title={
<h3 className="text-base font-medium">{t('setting.blueprints')}</h3>
}
>
<div className="space-y-4">
<SelectWithSearch
value={selectedValue}
onChange={handleSelect}
options={options}
placeholder={t('common.selectPlaceholder')}
/>
<div className="space-y-4">
<RAGFlowFormItem
name={instructionPath}
label={t('setting.instruction')}
>
<Textarea rows={6} />
</RAGFlowFormItem>
<div className="flex h-[50vh] min-h-0 flex-col">
<MarkdownEditor
content={String(pageExample ?? '')}
onChange={handlePageExampleChange}
/>
</div>
</div>
</div>
</Collapse>
</section>
);
}

View File

@@ -0,0 +1,54 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Pencil, Trash2 } from 'lucide-react';
type FieldCardProps = {
title?: string;
field: Record<string, string>;
onEdit: () => void;
onDelete: () => void;
};
export function FieldCard({ title, field, onEdit, onDelete }: FieldCardProps) {
return (
<Card className="border-border-button bg-transparent group">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-2 space-x-2 flex-1 min-w-0">
{title && (
<span className="text-sm font-medium text-text-primary">
{title}
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onEdit}
className="text-text-secondary hover:text-text-primary"
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onDelete}
className="text-text-secondary hover:text-state-error"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
{field.description && (
<p className="text-sm text-text-secondary line-clamp-3">
{field.description}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next';
import JsonEditor from '@/components/json-edit';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Braces } from 'lucide-react';
interface JsonPreviewSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
value: Record<string, unknown>;
}
export function JsonPreviewSheet({
open,
onOpenChange,
value,
}: JsonPreviewSheetProps) {
const { t } = useTranslation();
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Braces className="size-4" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent>{t('setting.jsonPreview')}</TooltipContent>
</Tooltip>
<SheetContent
className="w-1/2 max-w-[700px] flex flex-col"
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader>
<SheetTitle>{t('setting.jsonPreview')}</SheetTitle>
</SheetHeader>
<div className="flex-1 min-h-0 mt-4">
<JsonEditor
value={value}
height="100%"
options={{ mode: 'tree', modes: ['tree', 'code'] }}
defaultExpanded
/>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,72 @@
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Plus } from 'lucide-react';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FieldCard } from './field-card';
type SectionFieldGridProps = {
fieldsPath: string;
sectionName: string;
onOpenAddField: () => void;
onEditField: (index: number) => void;
};
export function SectionFieldGrid({
fieldsPath,
sectionName,
onOpenAddField,
onEditField,
}: SectionFieldGridProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove } = useFieldArray({
control: form.control,
name: fieldsPath,
});
const isTypedSection = sectionName === 'entity' || sectionName === 'relation';
const currentFields = useWatch({
control: form.control,
name: fieldsPath,
}) as Record<string, string>[] | undefined;
return (
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{fields.map((field, index) => {
const fieldValue = currentFields?.[index] ?? {};
return (
<FieldCard
key={field.id}
title={isTypedSection ? fieldValue.type : undefined}
field={fieldValue}
onEdit={() => onEditField(index)}
onDelete={() => remove(index)}
/>
);
})}
<Card
role="button"
tabIndex={0}
onClick={onOpenAddField}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onOpenAddField();
}
}}
className={cn(
'border-border-button bg-transparent border-dashed flex flex-col items-center justify-center gap-2 min-h-[140px] cursor-pointer',
'hover:border-border-accent hover:text-text-primary text-text-secondary',
)}
>
<CardContent className="flex flex-col items-center justify-center gap-2 p-4">
<Plus className="size-6" />
<span className="text-sm font-medium">{t('setting.addField')}</span>
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,242 @@
import { ModelTreeSelectFormField } from '@/components/model-tree-select';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { ICompilationTemplateBuiltin } from '@/interfaces/database/compilation-template';
import { startCase } from 'lodash';
import { ReactNode, useCallback } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { CompilationTemplateKind } from '@/constants/compilation';
import { TreeTemplateFields } from './tree-template-fields';
import { useTemplateKindChange } from '../hooks/use-template-kind-change';
import { FormSchemaType } from '../schema';
import { SectionTitleKeyMap } from '../utils';
import { useActiveSectionTab } from '../hooks/use-active-section-tab';
import { useAvailableKindOptions } from '../hooks/use-available-kind-options';
import { useBuiltinTemplate } from '../hooks/use-builtin-template';
import { useFieldArrayHandlers } from '../hooks/use-field-array-handlers';
import { useFieldModal } from '../hooks/use-field-modal';
import { useTemplatePreviewSheets } from '../hooks/use-template-preview-sheets';
import { useTemplateSectionData } from '../hooks/use-template-section-data';
import { AddFieldModal } from './add-field-modal';
import { SectionFieldGrid } from './section-field-grid';
import { TemplatePreviewHeader } from './template-preview-header';
type TemplateConfigurationProps = {
form: UseFormReturn<FormSchemaType>;
builtins: ICompilationTemplateBuiltin[];
kindOptions: { label: string; value: string }[];
selectedTemplateIndex: number;
children?: ReactNode;
};
export function TemplateConfiguration({
form,
builtins,
kindOptions,
selectedTemplateIndex,
children,
}: TemplateConfigurationProps) {
const { t } = useTranslation();
const {
addFieldModalOpen,
editingFieldIndex,
setEditingFieldIndex,
handleModalOpenChange,
handleOpenAddField,
handleOpenEditField,
} = useFieldModal();
const kind = useWatch({
control: form.control,
name: `templates.${selectedTemplateIndex}.kind`,
});
const availableKindOptions = useAvailableKindOptions(
form,
kindOptions,
selectedTemplateIndex,
);
const { builtinTemplate, sectionNames } = useBuiltinTemplate(builtins, kind);
const {
jsonSheetOpen,
setJsonSheetOpen,
workflowSheetOpen,
setWorkflowSheetOpen,
allFormValues,
templateName,
} = useTemplatePreviewSheets(form, selectedTemplateIndex);
const { activeSectionTab, setActiveSectionTab } =
useActiveSectionTab(sectionNames);
const handleKindChange = useTemplateKindChange({
form,
index: selectedTemplateIndex,
builtins,
});
const { activeFieldsPath, builtinSection, editingField } =
useTemplateSectionData(
form,
selectedTemplateIndex,
activeSectionTab,
builtinTemplate,
editingFieldIndex,
);
const { handleAddField } = useFieldArrayHandlers(
form,
activeFieldsPath,
editingFieldIndex,
setEditingFieldIndex,
);
const renderSectionTabs = useCallback(
(sectionName: string) => {
return (
sectionName === activeSectionTab && (
<SectionFieldGrid
key={activeFieldsPath}
fieldsPath={activeFieldsPath}
sectionName={sectionName}
onOpenAddField={handleOpenAddField}
onEditField={handleOpenEditField}
/>
)
);
},
[
activeFieldsPath,
activeSectionTab,
handleOpenAddField,
handleOpenEditField,
],
);
return (
<>
<TemplatePreviewHeader
templateName={templateName}
jsonSheetOpen={jsonSheetOpen}
onJsonSheetOpenChange={setJsonSheetOpen}
workflowSheetOpen={workflowSheetOpen}
onWorkflowSheetOpenChange={setWorkflowSheetOpen}
allFormValues={allFormValues}
/>
<div className="flex-1 min-h-0 overflow-y-auto p-5">
<div className="max-w-4xl mx-auto space-y-6">
<RAGFlowFormItem
name={`templates.${selectedTemplateIndex}.name`}
label={t('setting.templateName')}
required
>
<Input placeholder={t('common.namePlaceholder')} />
</RAGFlowFormItem>
<RAGFlowFormItem
name={`templates.${selectedTemplateIndex}.description`}
label={t('setting.templateDescription')}
>
<Textarea
placeholder={t('common.descriptionPlaceholder')}
rows={2}
resize="vertical"
/>
</RAGFlowFormItem>
<ModelTreeSelectFormField
name={`templates.${selectedTemplateIndex}.llm_id`}
label={t('setting.llmForExtraction')}
required
/>
<RAGFlowFormItem
name={`templates.${selectedTemplateIndex}.kind`}
label={t('knowledgeCompilation.builtinTemplates')}
required
>
{(field) => (
<SelectWithSearch
value={field.value}
onChange={(value) => handleKindChange(field, value)}
disabled={field.disabled}
options={availableKindOptions}
placeholder={t('common.selectPlaceholder')}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name={`templates.${selectedTemplateIndex}.config.global_rules`}
label={t('setting.globalRules')}
>
<Textarea
placeholder={t('setting.globalRulesPlaceholder')}
rows={8}
resize="vertical"
/>
</RAGFlowFormItem>
{kind === CompilationTemplateKind.Tree ? (
<TreeTemplateFields index={selectedTemplateIndex} />
) : (
sectionNames.length > 0 &&
activeSectionTab && (
<Tabs
value={activeSectionTab}
onValueChange={setActiveSectionTab}
className="w-full"
>
<TabsList className="w-full justify-start">
{sectionNames.map((sectionName) => (
<TabsTrigger
key={sectionName}
value={sectionName}
className="flex-1"
>
{t(
SectionTitleKeyMap[sectionName] ??
startCase(sectionName),
)}
</TabsTrigger>
))}
</TabsList>
{sectionNames.map((sectionName) => (
<TabsContent
key={sectionName}
value={sectionName}
className="mt-4"
>
{renderSectionTabs(sectionName)}
</TabsContent>
))}
</Tabs>
)
)}
{children}
</div>
</div>
<AddFieldModal
open={addFieldModalOpen}
onOpenChange={handleModalOpenChange}
sectionName={activeSectionTab}
builtinSection={builtinSection}
initialField={editingField}
onAdd={handleAddField}
/>
</>
);
}

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { JsonPreviewSheet } from './json-preview-sheet';
import { WorkflowPreviewSheet } from './workflow-preview-sheet';
interface TemplatePreviewHeaderProps {
templateName: string | undefined;
jsonSheetOpen: boolean;
onJsonSheetOpenChange: (open: boolean) => void;
workflowSheetOpen: boolean;
onWorkflowSheetOpenChange: (open: boolean) => void;
allFormValues: Record<string, unknown>;
}
export function TemplatePreviewHeader({
templateName,
jsonSheetOpen,
onJsonSheetOpenChange,
workflowSheetOpen,
onWorkflowSheetOpenChange,
allFormValues,
}: TemplatePreviewHeaderProps) {
const { t } = useTranslation();
return (
<section className="shrink-0 flex justify-between items-center px-5 py-4 border-b border-border-button">
<span className="text-lg font-medium text-text-primary">
{templateName || t('setting.templateName')}
</span>
<div className="flex items-center gap-2">
<JsonPreviewSheet
open={jsonSheetOpen}
onOpenChange={onJsonSheetOpenChange}
value={allFormValues}
/>
<WorkflowPreviewSheet
open={workflowSheetOpen}
onOpenChange={onWorkflowSheetOpenChange}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,52 @@
import { Collapse } from '@/components/collapse';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { SliderInputFormField } from '@/components/slider-input-form-field';
import { SwitchFormField } from '@/components/switch-fom-field';
import { Textarea } from '@/components/ui/textarea';
import { useTranslation } from 'react-i18next';
type TreeTemplateFieldsProps = {
index: number;
};
export function TreeTemplateFields({ index }: TreeTemplateFieldsProps) {
const { t } = useTranslation();
return (
<Collapse defaultOpen title={t('setting.raptorTreeSettings')}>
<div className="space-y-4">
<RAGFlowFormItem
name={`templates.${index}.config.raptor.prompt`}
label={t('setting.summarizationPrompt')}
>
<Textarea
placeholder={t('setting.descriptionPlaceholder')}
rows={6}
/>
</RAGFlowFormItem>
<SliderInputFormField
name={`templates.${index}.config.raptor.max_token`}
label={t('setting.maxToken')}
max={2048}
min={0}
step={1}
/>
<SliderInputFormField
name={`templates.${index}.config.raptor.threshold`}
label={t('setting.threshold')}
step={0.01}
max={1}
min={0}
/>
<SwitchFormField
name={`templates.${index}.config.raptor.rechunk`}
label={t('setting.rechunkByTreeLeaves')}
tooltip={t('setting.rechunkByTreeLeavesTip')}
vertical={false}
/>
</div>
</Collapse>
);
}

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Workflow } from 'lucide-react';
interface WorkflowPreviewSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WorkflowPreviewSheet({
open,
onOpenChange,
}: WorkflowPreviewSheetProps) {
const { t } = useTranslation();
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Workflow className="size-4" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent>{t('setting.processFlow')}</TooltipContent>
</Tooltip>
<SheetContent
className="w-1/2 max-w-[700px] flex flex-col"
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader>
<SheetTitle>{t('setting.processFlow')}</SheetTitle>
</SheetHeader>
<div className="flex-1 min-h-0 mt-4 flex items-center justify-center">
<span className="text-text-disabled">
{t('setting.processFlowComingSoon')}
</span>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,13 @@
import { useEffect, useState } from 'react';
export const useActiveSectionTab = (sectionNames: string[]) => {
const [activeSectionTab, setActiveSectionTab] = useState<string>('');
useEffect(() => {
setActiveSectionTab((prev) =>
sectionNames.includes(prev) ? prev : (sectionNames[0] ?? ''),
);
}, [sectionNames]);
return { activeSectionTab, setActiveSectionTab };
};

View File

@@ -0,0 +1,115 @@
import { ICompilationTemplateSection } from '@/interfaces/database/compilation-template';
import {
createEmptyField,
getFieldKeyOrder,
getTypeOptionsFromBuiltinSection,
} from '../utils';
import { useCallback, useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
type UseAddFieldFormOptions = {
open: boolean;
builtinSection?: ICompilationTemplateSection;
initialField?: Record<string, string>;
};
export const useAddFieldForm = ({
open,
builtinSection,
initialField,
}: UseAddFieldFormOptions) => {
const form = useForm<Record<string, string>>({
defaultValues: {},
});
const fieldKeys = useMemo(() => {
const firstField = builtinSection?.fields?.[0];
const keys = firstField
? Object.keys(firstField)
: ['type', 'description', 'rule'];
return getFieldKeyOrder(keys);
}, [builtinSection]);
const hasTypeField = fieldKeys.includes('type');
const typeOptions = useMemo(
() => getTypeOptionsFromBuiltinSection(builtinSection),
[builtinSection],
);
const buildField = useCallback(
(typeValue: string) => {
const matched = builtinSection?.fields?.find(
(field) => field.type === typeValue,
);
if (matched) {
const normalized: Record<string, string> = {};
fieldKeys.forEach((key) => {
normalized[key] = (matched as Record<string, string>)[key] ?? '';
});
return normalized;
}
const empty = createEmptyField(fieldKeys);
if (hasTypeField) {
empty.type = typeValue;
}
return empty;
},
[builtinSection, fieldKeys, hasTypeField],
);
useEffect(() => {
if (!open) return;
if (initialField) {
const normalized: Record<string, string> = {};
fieldKeys.forEach((key) => {
normalized[key] = initialField[key] ?? '';
});
form.reset(normalized);
return;
}
const firstType = typeOptions[0]?.value ?? '';
form.reset(buildField(firstType));
}, [buildField, fieldKeys, form, initialField, open, typeOptions]);
const handleTypeChange = useCallback(
(value: string) => {
if (initialField) {
form.setValue('type', value, {
shouldDirty: true,
shouldTouch: true,
});
return;
}
const nextField = buildField(value);
Object.entries(nextField).forEach(([key, val]) => {
form.setValue(key, val, {
shouldDirty: true,
shouldTouch: true,
});
});
},
[buildField, form, initialField],
);
const handleSubmit = useCallback(
(onAdd: (field: Record<string, string>) => void) => {
return form.handleSubmit((values) => {
onAdd(values);
});
},
[form],
);
return {
form,
fieldKeys,
hasTypeField,
typeOptions,
handleTypeChange,
handleSubmit,
};
};

View File

@@ -0,0 +1,53 @@
import { CompilationTemplateKind } from '@/constants/compilation';
import { FormSchemaType } from '../schema';
import { useMemo } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
export const useAvailableKindOptions = (
form: UseFormReturn<FormSchemaType>,
kindOptions: { label: string; value: string }[],
selectedTemplateIndex: number,
) => {
const kind = useWatch({
control: form.control,
name: `templates.${selectedTemplateIndex}.kind`,
});
const templates = useWatch({ control: form.control, name: 'templates' });
const availableKindOptions = useMemo(() => {
const otherSelectedKinds = new Set(
templates
?.filter((_, index) => index !== selectedTemplateIndex)
.map((template) => template.kind)
.filter((value): value is string => Boolean(value)) ?? [],
);
const hasOtherNonArtifactsKind = Array.from(otherSelectedKinds).some(
(value) => value !== CompilationTemplateKind.Artifacts,
);
const hasOtherArtifactsKind = otherSelectedKinds.has(
CompilationTemplateKind.Artifacts,
);
return kindOptions.filter((option) => {
if (option.value === kind) return true;
if (otherSelectedKinds.has(option.value)) return false;
if (
hasOtherNonArtifactsKind &&
option.value === CompilationTemplateKind.Artifacts
) {
return false;
}
if (
hasOtherArtifactsKind &&
option.value !== CompilationTemplateKind.Artifacts
) {
return false;
}
return true;
});
}, [kindOptions, kind, selectedTemplateIndex, templates]);
return availableKindOptions;
};

View File

@@ -0,0 +1,132 @@
import { SelectWithSearchFlagOptionType } from '@/components/originui/select-with-search';
import { IWikiPreset } from '@/interfaces/database/compilation-template';
import { useCallback, useMemo, useState } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FormSchemaType } from '../schema';
export const CustomBlueprintValue = '__custom__';
type UseBlueprintSelectionParams = {
form: UseFormReturn<FormSchemaType>;
selectedTemplateIndex: number;
presets: IWikiPreset[];
};
const isSameBlueprintContent = (
preset: IWikiPreset,
instruction: string,
pageExample: string,
) =>
preset.instruction.trim() === instruction.trim() &&
preset.page_example.trim() === pageExample.trim();
export function useBlueprintSelection({
form,
selectedTemplateIndex,
presets,
}: UseBlueprintSelectionParams) {
const { t } = useTranslation();
const [explicitValue, setExplicitValue] = useState<string>();
const instructionPath =
`templates.${selectedTemplateIndex}.config.instruction` as const;
const pageExamplePath =
`templates.${selectedTemplateIndex}.config.page_example` as const;
const useBlueprintPath =
`templates.${selectedTemplateIndex}.config.use_blueprint` as const;
const instruction = useWatch({
control: form.control,
name: instructionPath,
});
const pageExample = useWatch({
control: form.control,
name: pageExamplePath,
});
const matchedPresetId = useMemo(() => {
const currentInstruction = String(instruction ?? '');
const currentPageExample = String(pageExample ?? '');
if (!currentInstruction.trim() && !currentPageExample.trim()) {
return undefined;
}
return presets.find((preset) =>
isSameBlueprintContent(preset, currentInstruction, currentPageExample),
)?.id;
}, [instruction, pageExample, presets]);
const selectedValue =
explicitValue ?? matchedPresetId ?? CustomBlueprintValue;
const options = useMemo<SelectWithSearchFlagOptionType[]>(
() => [
...presets.map((preset) => ({
label: preset.id,
value: preset.id,
})),
{ label: t('setting.custom'), value: CustomBlueprintValue },
],
[presets, t],
);
const handleSelect = useCallback(
(value: string) => {
if (value === selectedValue) return;
setExplicitValue(value);
if (value === CustomBlueprintValue) {
const defaultConfig =
form.formState.defaultValues?.templates?.[selectedTemplateIndex]
?.config;
form.setValue(
instructionPath,
String(defaultConfig?.instruction ?? ''),
{ shouldValidate: false },
);
form.setValue(
pageExamplePath,
String(defaultConfig?.page_example ?? ''),
{ shouldValidate: false },
);
} else {
const preset = presets.find((item) => item.id === value);
if (!preset) return;
form.setValue(instructionPath, preset.instruction, {
shouldValidate: false,
});
form.setValue(pageExamplePath, preset.page_example, {
shouldValidate: false,
});
}
form.setValue(useBlueprintPath, true, { shouldValidate: false });
},
[
form,
instructionPath,
pageExamplePath,
presets,
selectedTemplateIndex,
selectedValue,
useBlueprintPath,
],
);
const handlePageExampleChange = useCallback(
(value: string) => {
form.setValue(pageExamplePath, value, { shouldValidate: false });
},
[form, pageExamplePath],
);
return {
selectedValue,
options,
handleSelect,
instructionPath,
pageExample,
handlePageExampleChange,
};
}

View File

@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import {
ICompilationTemplateBuiltin,
ICompilationTemplateSection,
} from '@/interfaces/database/compilation-template';
import { isConfigMetaKey, sortSectionNames } from '../utils';
export const useBuiltinTemplate = (
builtins: ICompilationTemplateBuiltin[],
kind: string,
) => {
const builtinTemplate = useMemo(
() => builtins.find((template) => template.kind === kind),
[builtins, kind],
);
const sectionNames = useMemo(() => {
const names = Object.keys(builtinTemplate?.config ?? {}).filter((key) => {
if (isConfigMetaKey(key)) return false;
const section = builtinTemplate?.config?.[key];
return (
section &&
typeof section === 'object' &&
Array.isArray((section as ICompilationTemplateSection).fields)
);
});
return sortSectionNames(names);
}, [builtinTemplate]);
return { builtinTemplate, sectionNames };
};

View File

@@ -0,0 +1,43 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template';
import { buildFormSchema, FormSchemaType } from '../schema';
import { DefaultValues, transformGroupDetailToForm } from '../utils';
type UseCompilationTemplateGroupFormOptions = {
detail?: ICompilationTemplateGroup;
defaultLlmId?: string;
isCreate: boolean;
};
export const useCompilationTemplateGroupForm = ({
detail,
defaultLlmId,
isCreate,
}: UseCompilationTemplateGroupFormOptions) => {
const { t } = useTranslation();
const form = useForm<FormSchemaType>({
resolver: zodResolver(buildFormSchema(t)),
defaultValues: DefaultValues,
mode: 'onChange',
});
useEffect(() => {
if (detail) {
form.reset(transformGroupDetailToForm(detail));
} else if (
isCreate &&
defaultLlmId &&
!form.getValues('templates.0.llm_id')
) {
form.setValue('templates.0.llm_id', defaultLlmId);
}
}, [defaultLlmId, detail, form, isCreate]);
return { form };
};

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import {
ICreateCompilationTemplateGroupRequestBody,
IUpdateCompilationTemplateGroupRequestBody,
} from '@/interfaces/request/compilation-template';
import { FormSchemaType } from '../schema';
import { transformFormToPayload } from '../utils';
type UseCompilationTemplateGroupSubmitOptions = {
isCreate: boolean;
id?: string;
createGroup: (
params: ICreateCompilationTemplateGroupRequestBody,
) => Promise<{ code: number } & Record<string, unknown>>;
updateGroup: (
id: string,
params: IUpdateCompilationTemplateGroupRequestBody,
) => Promise<{ code: number } & Record<string, unknown>>;
onSuccess: () => void;
};
export const useCompilationTemplateGroupSubmit = ({
isCreate,
id,
createGroup,
updateGroup,
onSuccess,
}: UseCompilationTemplateGroupSubmitOptions) => {
const onSubmit = useCallback(
async (values: FormSchemaType) => {
const payload = transformFormToPayload(values);
let result;
if (isCreate) {
result = await createGroup(payload);
} else if (id) {
result = await updateGroup(id, payload);
}
if (result?.code === 0) {
onSuccess();
}
},
[createGroup, id, isCreate, onSuccess, updateGroup],
);
return { onSubmit };
};

View File

@@ -0,0 +1,75 @@
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import {
useCreateCompilationTemplateGroup,
useFetchCompilationTemplateGroup,
useUpdateCompilationTemplateGroup,
} from '@/hooks/use-compilation-template-group-request';
import { useFetchBuiltinCompilationTemplates } from '@/hooks/use-compilation-template-request';
import { useFetchDefaultModelDictionary } from '@/hooks/use-llm-request';
import { isCreateCompilationTemplateGroup } from '@/utils/compilation-template-util';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { formatKindLabel } from '@/utils/compilation-template-util';
import { useCompilationTemplateGroupForm } from './use-compilation-template-group-form';
import { useCompilationTemplateGroupSubmit } from './use-compilation-template-group-submit';
type UseEditNextCompilationTemplateGroupOptions = {
onSuccess?: () => void;
};
export const useEditNextCompilationTemplateGroup = ({
onSuccess,
}: UseEditNextCompilationTemplateGroupOptions = {}) => {
const { id } = useParams<{ id: string }>();
const { navigateToCompilationTemplates } = useNavigatePage();
const { t } = useTranslation();
const isCreate = isCreateCompilationTemplateGroup(id);
const { data: detail } = useFetchCompilationTemplateGroup();
const { data: builtins, kindOptions: builtinKindOptions } =
useFetchBuiltinCompilationTemplates();
const defaultModelDictionary = useFetchDefaultModelDictionary();
const { createGroup, loading: createLoading } =
useCreateCompilationTemplateGroup();
const { updateGroup, loading: updateLoading } =
useUpdateCompilationTemplateGroup();
const kindOptions = useMemo(
() =>
builtinKindOptions.map((option) => ({
...option,
label: formatKindLabel(t, option.value),
})),
[builtinKindOptions, t],
);
const { form } = useCompilationTemplateGroupForm({
detail,
defaultLlmId: defaultModelDictionary.llm_id,
isCreate,
});
const { onSubmit } = useCompilationTemplateGroupSubmit({
isCreate,
id,
createGroup,
updateGroup,
onSuccess: onSuccess ?? navigateToCompilationTemplates,
});
return {
id,
form,
kindOptions,
builtins,
onSubmit,
isCreate,
isLoading: isCreate ? createLoading : updateLoading,
navigateToCompilationTemplates,
};
};

View File

@@ -0,0 +1,39 @@
import { useCallback } from 'react';
import { ArrayPath, UseFormReturn } from 'react-hook-form';
import { FormSchemaType } from '../schema';
export const useFieldArrayHandlers = (
form: UseFormReturn<FormSchemaType>,
activeFieldsPath: ArrayPath<FormSchemaType>,
editingFieldIndex: number | null,
setEditingFieldIndex: (index: number | null) => void,
) => {
const handleAddField = useCallback(
(field: Record<string, string>) => {
const currentFields =
(form.getValues(activeFieldsPath) as
| Record<string, string>[]
| undefined) ?? [];
if (editingFieldIndex !== null) {
const newFields = [...currentFields];
newFields[editingFieldIndex] = field;
form.setValue(activeFieldsPath, newFields, {
shouldValidate: false,
shouldDirty: true,
shouldTouch: true,
});
} else {
form.setValue(activeFieldsPath, [...currentFields, field], {
shouldValidate: false,
shouldDirty: true,
shouldTouch: true,
});
}
setEditingFieldIndex(null);
},
[activeFieldsPath, editingFieldIndex, form, setEditingFieldIndex],
);
return { handleAddField };
};

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from 'react';
export const useFieldModal = () => {
const [addFieldModalOpen, setAddFieldModalOpen] = useState(false);
const [editingFieldIndex, setEditingFieldIndex] = useState<number | null>(
null,
);
const handleModalOpenChange = useCallback(
(open: boolean) => {
setAddFieldModalOpen(open);
if (!open) setEditingFieldIndex(null);
},
[setAddFieldModalOpen],
);
const handleOpenAddField = useCallback(() => {
setEditingFieldIndex(null);
setAddFieldModalOpen(true);
}, []);
const handleOpenEditField = useCallback((index: number) => {
setEditingFieldIndex(index);
setAddFieldModalOpen(true);
}, []);
return {
addFieldModalOpen,
editingFieldIndex,
setEditingFieldIndex,
handleModalOpenChange,
handleOpenAddField,
handleOpenEditField,
};
};

View File

@@ -0,0 +1,42 @@
import { ICompilationTemplateBuiltin } from '@/interfaces/database/compilation-template';
import { UseFormReturn } from 'react-hook-form';
import { FormSchemaType } from '../schema';
import { buildConfigFromBuiltin } from '../utils';
type FieldLike = {
value: string;
onChange: (value: string) => void;
};
type UseTemplateKindChangeOptions = {
form: UseFormReturn<FormSchemaType>;
index: number;
builtins: ICompilationTemplateBuiltin[];
};
export const useTemplateKindChange = ({
form,
index,
builtins,
}: UseTemplateKindChangeOptions) => {
return (field: FieldLike, value: string) => {
if (value && value !== field.value) {
const builtinTemplate = builtins.find(
(template) => template.kind === value,
);
if (builtinTemplate) {
form.setValue(
`templates.${index}.config`,
buildConfigFromBuiltin(
builtinTemplate,
value,
form.getValues(`templates.${index}.llm_id`),
),
{ shouldValidate: false },
);
}
}
field.onChange(value);
};
};

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { FormSchemaType } from '../schema';
export function useTemplatePreviewSheets(
form: UseFormReturn<FormSchemaType>,
selectedTemplateIndex: number,
) {
const [jsonSheetOpen, setJsonSheetOpen] = useState(false);
const [workflowSheetOpen, setWorkflowSheetOpen] = useState(false);
const allFormValues = useWatch({ control: form.control });
const templateName = useWatch({
control: form.control,
name: `templates.${selectedTemplateIndex}.name`,
});
return {
jsonSheetOpen,
setJsonSheetOpen,
workflowSheetOpen,
setWorkflowSheetOpen,
allFormValues,
templateName,
};
}

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import { ArrayPath, UseFormReturn } from 'react-hook-form';
import {
ICompilationTemplateBuiltin,
ICompilationTemplateSection,
} from '@/interfaces/database/compilation-template';
import { FormSchemaType } from '../schema';
export const useTemplateSectionData = (
form: UseFormReturn<FormSchemaType>,
selectedTemplateIndex: number,
activeSectionTab: string,
builtinTemplate: ICompilationTemplateBuiltin | undefined,
editingFieldIndex: number | null,
) => {
const activeSectionPath = `templates.${selectedTemplateIndex}.config.${activeSectionTab}`;
const activeFieldsPath =
`${activeSectionPath}.fields` as ArrayPath<FormSchemaType>;
const builtinSection = useMemo(() => {
return builtinTemplate?.config?.[activeSectionTab] as
| ICompilationTemplateSection
| undefined;
}, [activeSectionTab, builtinTemplate?.config]);
const editingField = useMemo(() => {
if (editingFieldIndex === null) return undefined;
return ((form.getValues(activeFieldsPath) as
| Record<string, string>[]
| undefined) ?? [])[editingFieldIndex];
}, [activeFieldsPath, editingFieldIndex, form]);
return {
activeSectionPath,
activeFieldsPath,
builtinSection,
editingField,
};
};

View File

@@ -0,0 +1,85 @@
import BackButton from '@/components/back-button';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { CompilationTemplateKind } from '@/constants/compilation';
import { useFetchCompilationTemplateGroup } from '@/hooks/use-compilation-template-group-request';
import { Routes } from '@/routes';
import { useCallback, useMemo } from 'react';
import { useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { BlueprintSection } from './components/blueprint-section';
import { TemplateConfiguration } from './components/template-configuration';
import { useEditNextCompilationTemplateGroup } from './hooks/use-edit-next-compilation-template-group';
const SelectedTemplateIndex = 0;
const agentsUrl = Routes.Agents;
export default function EditNextCompilationTemplate() {
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToAgents = useCallback(() => {
navigate(agentsUrl);
}, [navigate]);
const { form, kindOptions, builtins, onSubmit, isCreate, isLoading } =
useEditNextCompilationTemplateGroup({
onSuccess: navigateToAgents,
});
const { data: group } = useFetchCompilationTemplateGroup();
const selectedKind = useWatch({
control: form.control,
name: `templates.${SelectedTemplateIndex}.kind`,
});
const isArtifacts = selectedKind === CompilationTemplateKind.Artifacts;
const handleSave = useMemo(
() => form.handleSubmit(onSubmit),
[form, onSubmit],
);
return (
<section className="h-full flex flex-col bg-bg-base">
<header className="shrink-0 px-5 py-4 border-b border-border-button flex gap-3 items-center">
<BackButton to={agentsUrl} />
<h2 className="font-medium text-text-secondary">
{isCreate
? t('setting.addTemplateGroup')
: group?.name || t('setting.editTemplateGroup')}
</h2>
</header>
<Form {...form}>
<form className="flex-1 min-h-0 flex flex-col">
<TemplateConfiguration
form={form}
builtins={builtins}
kindOptions={kindOptions}
selectedTemplateIndex={SelectedTemplateIndex}
>
{isArtifacts && (
<BlueprintSection
form={form}
selectedTemplateIndex={SelectedTemplateIndex}
/>
)}
</TemplateConfiguration>
<footer className="shrink-0 px-5 py-4 border-t border-border-button flex items-center justify-end gap-5">
<Button type="button" variant="outline" onClick={navigateToAgents}>
{t('common.back')}
</Button>
<Button type="button" loading={isLoading} onClick={handleSave}>
{t('common.save')}
</Button>
</footer>
</form>
</Form>
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
export const buildSectionSchema = (t: (key: string) => string) =>
z.object({
description: z.string().optional(),
fields: z
.array(z.record(z.string().min(1, t('setting.fieldDescriptionRequired'))))
.min(1),
});
export const buildRaptorConfigSchema = (t: (key: string) => string) =>
z.object({
prompt: z.string().optional(),
max_token: z.number().min(1, t('setting.maxTokenRequired')),
threshold: z.number().min(0).max(1),
rechunk: z.boolean().optional(),
});
export const buildSynthesisSchema = () =>
z
.object({
compile_kwd: z.string().optional(),
enabled: z.boolean().optional(),
example: z.string().optional(),
})
.passthrough();
export const buildTemplateSchema = (t: (key: string) => string) =>
z.object({
id: z.string().optional(),
name: z.string().min(1, t('setting.templateNameRequired')),
description: z.string().optional(),
llm_id: z.string().min(1, t('setting.llmForExtractionRequired')),
kind: z.string().min(1, t('setting.templateKindRequired')),
config: z.record(
z.union([
buildRaptorConfigSchema(t),
buildSectionSchema(t),
buildSynthesisSchema(),
z.string(),
z.boolean(),
]),
),
});
export const buildFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().optional(),
description: z.string().optional(),
avatar: z.string().optional(),
templates: z.array(buildTemplateSchema(t)).min(1),
});
export type TemplateSchemaType = z.infer<
ReturnType<typeof buildTemplateSchema>
>;
export type FormSchemaType = z.infer<ReturnType<typeof buildFormSchema>>;

View File

@@ -0,0 +1,325 @@
import { isEqual } from 'lodash';
import {
ICompilationTemplate,
ICompilationTemplateBuiltin,
ICompilationTemplateGroup,
ICompilationTemplateRaptorConfig,
ICompilationTemplateSection,
} from '@/interfaces/database/compilation-template';
import { ICompilationTemplateConfigRequest } from '@/interfaces/request/compilation-template';
import { CompilationTemplateKind } from '@/constants/compilation';
import { FormSchemaType, TemplateSchemaType } from './schema';
export const DefaultFieldKeys = ['type', 'description', 'rule'];
export const splitExampleToBlueprintFields = (
example: string,
): { instruction: string; page_example: string } => {
const trimmed = example.trim();
const separatorIndex = trimmed.indexOf('\n\n');
if (separatorIndex === -1) {
return { instruction: trimmed, page_example: '' };
}
return {
instruction: trimmed.slice(0, separatorIndex).trim(),
page_example: trimmed.slice(separatorIndex + 2).trim(),
};
};
export const FieldKeyOrders = [
DefaultFieldKeys,
['statement', 'subject'],
['definition_excerpt', 'term'],
];
export const getFieldKeyOrder = (keys: string[]): string[] => {
const sortedKeys = [...keys].sort();
return (
FieldKeyOrders.find((order) => isEqual([...order].sort(), sortedKeys)) ??
keys
);
};
export const DefaultTemplateValues: TemplateSchemaType = {
id: undefined,
name: '',
description: '',
llm_id: '',
kind: '',
config: {
kind: '',
llm_id: '',
global_rules: '',
example: '',
instruction: '',
page_example: '',
use_blueprint: false,
},
};
export const DefaultValues: FormSchemaType = {
name: '',
description: '',
avatar: '',
templates: [DefaultTemplateValues],
};
export const isConfigMetaKey = (key: string) =>
[
'kind',
'llm_id',
'global_rules',
'example',
'instruction',
'page_example',
'synthesis',
'use_blueprint',
].includes(key);
export const createEmptyField = (keys: string[]) =>
Object.fromEntries(keys.map((key) => [key, '']));
export const normalizeSection = (
section?: ICompilationTemplateSection,
): ICompilationTemplateSection => {
const fields = section?.fields ?? [];
return {
description: section?.description ?? '',
fields:
fields.length > 0
? fields.map((field) =>
Object.fromEntries(
Object.entries(field).map(([key, value]) => [key, value ?? '']),
),
)
: [createEmptyField(DefaultFieldKeys)],
};
};
export const buildConfigFromBuiltin = (
builtinTemplate: ICompilationTemplateBuiltin,
kind: string,
llmId: string,
): TemplateSchemaType['config'] => {
const example =
typeof builtinTemplate.config?.example === 'string'
? builtinTemplate.config.example
: '';
const sections: TemplateSchemaType['config'] = {
kind,
llm_id: llmId,
global_rules:
typeof builtinTemplate.config?.global_rules === 'string'
? builtinTemplate.config.global_rules
: '',
example:
typeof builtinTemplate.config?.example === 'string'
? builtinTemplate.config.example
: '',
...(typeof builtinTemplate.config?.synthesis === 'object' &&
builtinTemplate.config?.synthesis !== null
? {
synthesis: builtinTemplate.config
.synthesis as TemplateSchemaType['config']['synthesis'],
}
: {}),
use_blueprint:
kind === CompilationTemplateKind.Artifacts && example.length > 0,
};
if (kind === CompilationTemplateKind.Artifacts && example.length > 0) {
const { instruction, page_example } =
splitExampleToBlueprintFields(example);
sections.instruction = instruction;
sections.page_example = page_example;
}
if (kind === CompilationTemplateKind.Tree) {
const builtinRaptor: ICompilationTemplateRaptorConfig =
builtinTemplate.config?.raptor ?? {};
return {
...sections,
raptor: {
prompt: builtinRaptor.prompt ?? '',
max_token: builtinRaptor.max_token ?? 512,
threshold: builtinRaptor.threshold ?? 0.1,
rechunk: builtinRaptor.rechunk ?? false,
},
};
}
Object.entries(builtinTemplate.config ?? {}).forEach(([key, value]) => {
if (isConfigMetaKey(key)) return;
sections[key] = normalizeSection(
value as ICompilationTemplateSection,
) as TemplateSchemaType['config'][string];
});
return sections;
};
export const transformDetailToForm = (
detail: ICompilationTemplate,
): TemplateSchemaType => {
const config = detail.config ?? {};
const example = typeof config.example === 'string' ? config.example : '';
const base: TemplateSchemaType['config'] = {
kind: config.kind ?? '',
llm_id: config.llm_id ?? '',
global_rules: config.global_rules ?? '',
example: typeof config.example === 'string' ? config.example : '',
...(typeof config.synthesis === 'object' && config.synthesis !== null
? {
synthesis:
config.synthesis as TemplateSchemaType['config']['synthesis'],
}
: {}),
use_blueprint:
detail.kind === CompilationTemplateKind.Artifacts && example.length > 0,
};
if (detail.kind === CompilationTemplateKind.Artifacts && example.length > 0) {
const { instruction, page_example } =
splitExampleToBlueprintFields(example);
base.instruction = instruction;
base.page_example = page_example;
}
if (detail.kind === CompilationTemplateKind.Tree) {
const raptor: ICompilationTemplateRaptorConfig = config.raptor ?? {};
return {
id: detail.id,
name: detail.name ?? '',
description: detail.description ?? '',
llm_id: config.llm_id ?? '',
kind: detail.kind ?? '',
config: {
...base,
raptor: {
prompt: raptor.prompt ?? '',
max_token: raptor.max_token ?? 512,
threshold: raptor.threshold ?? 0.1,
rechunk: raptor.rechunk ?? false,
},
},
};
}
Object.entries(config).forEach(([key, value]) => {
if (isConfigMetaKey(key)) return;
base[key] = normalizeSection(
value as ICompilationTemplateSection,
) as TemplateSchemaType['config'][string];
});
return {
id: detail.id,
name: detail.name ?? '',
description: detail.description ?? '',
llm_id: config.llm_id ?? '',
kind: detail.kind ?? '',
config: base,
};
};
export const transformGroupDetailToForm = (
detail: ICompilationTemplateGroup,
): FormSchemaType => {
const templates = (detail.templates ?? []).map((template) =>
transformDetailToForm(template),
);
return {
name: detail.name ?? '',
description: detail.description ?? '',
avatar: detail.avatar ?? '',
templates: templates.length > 0 ? templates : [DefaultTemplateValues],
};
};
export const transformTemplateToPayload = (template: TemplateSchemaType) => {
const config: ICompilationTemplateConfigRequest = {
kind: template.kind,
llm_id: template.llm_id,
};
Object.entries(template.config).forEach(([key, value]) => {
if (key === 'kind' || key === 'llm_id') return;
if (key === 'instruction' || key === 'page_example') return;
if (key === 'synthesis') {
config[key] = value as ICompilationTemplateConfigRequest[string];
return;
}
if (isConfigMetaKey(key)) {
if (typeof value === 'string') config[key] = value;
} else {
config[key] = value as ICompilationTemplateConfigRequest[string];
}
});
if (template.kind === CompilationTemplateKind.Artifacts) {
if (template.config.use_blueprint) {
const instruction = String(template.config.instruction ?? '').trim();
const pageExample = String(template.config.page_example ?? '').trim();
config.example = [instruction, pageExample].filter(Boolean).join('\n\n');
} else {
config.example = '';
}
}
return {
id: template.id,
name: template.name,
description: template.description,
kind: template.kind,
config,
};
};
export const transformFormToPayload = (values: FormSchemaType) => {
const template = values.templates[0];
return {
name: template?.name ?? values.name ?? '',
description: template?.description ?? values.description,
avatar: values.avatar || undefined,
templates: values.templates.map((template) =>
transformTemplateToPayload(template),
),
};
};
export const SectionTitleKeyMap: Record<string, string> = {
entity: 'setting.entitySpecification',
relation: 'setting.relationSpecification',
concept: 'setting.conceptSpecification',
claim: 'setting.claimSpecification',
};
export const SectionPriority = ['entity', 'relation'];
export const sortSectionNames = (names: string[]): string[] => {
const priority = SectionPriority.filter((name) => names.includes(name));
const rest = names.filter((name) => !SectionPriority.includes(name));
return [...priority, ...rest];
};
export const FieldLabelKeyMap: Record<string, string> = {
type: 'setting.fieldType',
description: 'setting.fieldDescription',
rule: 'setting.fieldRule',
};
export const getTypeOptionsFromBuiltinSection = (
builtinSection?: ICompilationTemplateSection,
) => {
const typeSet = new Set<string>();
builtinSection?.fields?.forEach((field) => {
if (field.type) typeSet.add(field.type);
});
return Array.from(typeSet)
.sort()
.map((value) => ({ label: value, value }));
};

View File

@@ -27,7 +27,10 @@ export default function CompilationTemplates() {
} = useFetchCompilationTemplateGroupsByPage();
const { deleteGroup } = useDeleteCompilationTemplateGroup();
const { navigateToCompilationTemplate } = useNavigatePage();
const {
navigateToCompilationTemplate,
navigateToCompilationTemplateEditNext,
} = useNavigatePage();
const handlePageChange = useCallback(
(page: number, pageSize?: number) => {
@@ -40,6 +43,10 @@ export default function CompilationTemplates() {
navigateToCompilationTemplate('create')();
}, [navigateToCompilationTemplate]);
const handleAddNext = useCallback(() => {
navigateToCompilationTemplateEditNext()();
}, [navigateToCompilationTemplateEditNext]);
const handleDelete = useCallback(
async (id: string) => {
await deleteGroup(id);
@@ -73,6 +80,10 @@ export default function CompilationTemplates() {
<Plus />
{t('setting.addTemplateGroup')}
</Button>
<Button onClick={handleAddNext}>
<Plus />
Next
</Button>
</div>
</header>
}
@@ -85,7 +96,7 @@ export default function CompilationTemplates() {
<TemplateCard
key={item.id}
data={item}
onClick={navigateToCompilationTemplate(item.id)}
onClick={navigateToCompilationTemplateEditNext(item.id)}
onDelete={handleDelete}
/>
))}

View File

@@ -6,6 +6,7 @@ import { CompilationTemplateScope } from '@/constants/compilation';
import { ICompilationTemplateGroup } from '@/interfaces/database/compilation-template';
import { Database, FileText, LucideIcon } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { formatKindLabel } from '@/utils/compilation-template-util';
import { TemplateDropdown } from './template-dropdown';
@@ -28,6 +29,7 @@ function ScopeIcon({ scope }: { scope?: string }) {
}
export function TemplateCard({ data, onClick, onDelete }: TemplateCardProps) {
const { t } = useTranslation();
const kinds = useMemo(
() => Array.from(new Set((data.templates ?? []).map((item) => item.kind))),
[data.templates],
@@ -63,7 +65,7 @@ export function TemplateCard({ data, onClick, onDelete }: TemplateCardProps) {
<div className="flex flex-wrap gap-2 mt-2">
{kinds.map((kind) => (
<Badge key={kind} variant="secondary">
{formatKindLabel(kind)}
{formatKindLabel(t, kind)}
</Badge>
))}
</div>

View File

@@ -12,7 +12,6 @@ import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { TFunction } from 'i18next';
import {
Columns3Cog,
LucideBox,
LucideLogOut,
LucideMessagesSquare,
@@ -47,11 +46,6 @@ const menuItems = (t: TFunction) => [
label: 'MCP',
key: Routes.Mcp,
},
{
icon: <Columns3Cog className="size-[1em]" />,
label: t('setting.compilationTemplates'),
key: Routes.CompilationTemplates,
},
{
icon: <LucideUsers className="size-[1em]" />,
label: t('setting.team'),

View File

@@ -45,6 +45,7 @@ export enum Routes {
Prompt = '/prompt',
CompilationTemplates = '/compilation-templates',
CompilationTemplatesCreateNext = '/compilation-templates/create-next',
CompilationTemplatesEditNext = '/compilation-templates/edit-next',
DataSource = '/data-source',
DataSourceDetailPage = '/data-source-detail-page',
ChatChannel = '/chat-channel',
@@ -334,6 +335,18 @@ const routeConfigOptions = [
Component: () =>
import('@/pages/user-setting/compilation-templates/create-next'),
},
{
path: Routes.CompilationTemplatesEditNext,
layout: false,
Component: () =>
import('@/pages/user-setting/compilation-templates/edit-next'),
},
{
path: `${Routes.CompilationTemplatesEditNext}/:id`,
layout: false,
Component: () =>
import('@/pages/user-setting/compilation-templates/edit-next'),
},
{
path: `${Routes.SearchShare}`,
Component: () => import('@/pages/next-search/share'),

View File

@@ -4,7 +4,11 @@ import request from '@/utils/next-request';
export const getDocumentStructureGraph = (
datasetId: string,
documentId: string,
) => request.get(api.documentStructureGraph(datasetId, documentId));
keywords?: string,
) =>
request.get(api.documentStructureGraph(datasetId, documentId), {
params: keywords ? { keywords } : undefined,
});
export const deleteDocumentStructureGraph = (
datasetId: string,

View File

@@ -1,5 +1,6 @@
import { IRenameTag } from '@/interfaces/database/dataset';
import {
IFetchArtifactGraphRequestParams,
IFetchArtifactListRequestParams,
IFetchArtifactTopicListRequestParams,
IFetchDocumentListRequestBody,
@@ -415,9 +416,21 @@ export const getArtifactPage = (
export const getArtifactGraph = (
datasetId: string,
params?: { node?: string },
params?: IFetchArtifactGraphRequestParams,
) => request.get(api.getArtifactGraph(datasetId), { params });
export const getArtifactsAlteration = (datasetId: string) =>
request.get(api.artifactsAlteration(datasetId));
export const getArtifactsStructure = (
datasetId: string,
kind: string,
keywords?: string,
) =>
request.get(api.artifactsStructure(datasetId), {
params: keywords ? { kind, keywords } : { kind },
});
export const updateArtifactPage = (
datasetId: string,
pageType: string,

View File

@@ -159,6 +159,8 @@ export default {
`${restAPIv1}/datasets/${datasetId}/ingestions/summary`,
artifactsList: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts`,
artifactsAlteration: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts/alteration`,
artifactsTopicList: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts_topics`,
getArtifactPage: (datasetId: string, pageType: string, slug: string) =>
@@ -169,6 +171,8 @@ export default {
`${restAPIv1}/datasets/${datasetId}/commits/${commitId}`,
getArtifactGraph: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts/graph`,
artifactsStructure: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts_structure`,
clearWiki: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/artifacts`,
getDatasetSkillTree: (datasetId: string) =>

View File

@@ -1,6 +1,14 @@
import { CompilationTemplateKind } from '@/constants/compilation';
import { TFunction } from 'i18next';
import { capitalize, lowerCase } from 'lodash';
export function formatKindLabel(kind: string): string {
export function formatKindLabel(t: TFunction, kind: string): string {
if (kind === CompilationTemplateKind.Artifacts) {
return 'Wiki';
}
if (kind === CompilationTemplateKind.KnowledgeGraph) {
return t('knowledgeDetails.structureGraph');
}
return capitalize(lowerCase(kind));
}
export const isCreateCompilationTemplateGroup = (