Feat: Render the skills list using a tree view. (#17115)

### Summary
Feat: Render the skills list using a tree view.
This commit is contained in:
balibabu
2026-07-20 19:00:08 +08:00
committed by GitHub
parent aff9ff6d0a
commit 392b249404
33 changed files with 571 additions and 826 deletions

View File

@@ -37,7 +37,6 @@ import {
AutoQuestionsFormField,
} from '../auto-keywords-form-field';
import { ChildrenDelimiterForm } from '../children-delimiter-form';
import { CompilationTemplateFormField } from '../compilation-template-form-field';
import { DataFlowSelect } from '../data-pipeline-select';
import { DelimiterFormField } from '../delimiter-form-field';
import { EntityTypesFormField } from '../entity-types-form-field';
@@ -153,7 +152,6 @@ export function ChunkMethodDialog({
)
.optional(),
enable_metadata: z.boolean().optional(),
compilation_template_group_id: z.array(z.string()).optional(),
}),
})
.superRefine((data, ctx) => {
@@ -302,10 +300,6 @@ export function ChunkMethodDialog({
<ParseTypeItem />
{parseType === ParseType.BuiltIn && <ChunkMethodItem />}
{parseType === ParseType.BuiltIn && (
<CompilationTemplateFormField></CompilationTemplateFormField>
)}
{showPages && parseType === ParseType.BuiltIn && (
<DynamicPageRange />
)}

View File

@@ -42,7 +42,6 @@ export function useDefaultParserValues() {
metadata: [],
built_in_metadata: [],
enable_metadata: false,
compilation_template_group_id: [],
};
return defaultParserValues as IParserConfig;

View File

@@ -1,103 +1,25 @@
import { RAGFlowFormItem } from '@/components/ragflow-form';
import {
MultiSelect,
MultiSelectOptionType,
} from '@/components/ui/multi-select';
import { useFetchAllCompilationTemplateGroups } from '@/hooks/use-compilation-template-group-request';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithSearch } from './originui/select-with-search';
type CompilationTemplateFormFieldProps = {
horizontal?: boolean;
name?: string;
};
const ScopeTranslationKeyMap: Record<string, string> = {
file: 'scopeFile',
dataset: 'scopeDataset',
};
type CompilationTemplateMultiSelectProps = {
value?: string[];
onChange(value: string[]): void;
};
/**
* Renders a multi-select for compilation template groups.
*
* Selection rule:
* - Each group has a scope: "file" or "dataset".
* - At most one group per scope can be selected.
* - The two scopes can be combined (one file group + one dataset group).
* - Once a scope is selected, remaining options of the same scope are disabled.
* - The scope is displayed as a suffix after each option label.
*/
function CompilationTemplateMultiSelect({
value: rawValue = [],
onChange,
}: CompilationTemplateMultiSelectProps) {
const { t } = useTranslation();
const { groups } = useFetchAllCompilationTemplateGroups();
const value = useMemo(() => {
// Normalize legacy single-string values into an array during the migration.
if (Array.isArray(rawValue)) return rawValue;
if (typeof rawValue === 'string' && rawValue) return [rawValue];
return [];
}, [rawValue]);
// Collect scopes that are already selected by the current value.
const selectedScopes = useMemo(() => {
const scopes = new Set<string>();
value.forEach((id) => {
const group = groups?.find((g) => g.id === id);
if (group?.scope) {
scopes.add(group.scope);
}
});
return scopes;
}, [value, groups]);
const options = useMemo<MultiSelectOptionType[]>(() => {
return (groups ?? []).map((group) => {
const scopeTranslationKey = group.scope
? ScopeTranslationKeyMap[group.scope]
: undefined;
// Disable other options that share an already-selected scope.
const isSameScopeSelected =
!!group.scope &&
selectedScopes.has(group.scope) &&
!value.includes(group.id);
return {
label: group.name,
value: group.id,
disabled: isSameScopeSelected,
suffix: scopeTranslationKey ? (
<span className="text-text-secondary ml-1">
({t(`knowledgeConfiguration.${scopeTranslationKey}`)})
</span>
) : undefined,
};
});
}, [groups, selectedScopes, value, t]);
return (
<MultiSelect
options={options}
onValueChange={onChange}
defaultValue={value}
value={value}
showSelectAll={false}
/>
);
}
export function CompilationTemplateFormField({
horizontal,
name = 'parser_config.compilation_template_group_id',
}: CompilationTemplateFormFieldProps) {
const { t } = useTranslation();
const { groups } = useFetchAllCompilationTemplateGroups();
const options = useMemo(
() => groups?.map((group) => ({ label: group.name, value: group.id })),
[groups],
);
return (
<RAGFlowFormItem
@@ -107,9 +29,10 @@ export function CompilationTemplateFormField({
horizontal={horizontal}
>
{(field) => (
<CompilationTemplateMultiSelect
value={field.value ?? []}
<SelectWithSearch
value={field.value}
onChange={field.onChange}
options={options}
/>
)}
</RAGFlowFormItem>

View File

@@ -21,7 +21,9 @@ export default function MarkdownEditor({
const contentRef = useRef(content);
useEffect(() => {
contentRef.current = content;
if (content === contentRef.current) {
return;
}
if (showSource) setRawContent(content);
}, [showSource, content]);

View File

@@ -1,9 +1,11 @@
import message from '@/components/ui/message';
import {
DatasetSkillPage,
DatasetSkillTree,
} from '@/interfaces/database/dataset-skill';
import i18n from '@/locales/config';
import datasetSkillService from '@/services/dataset-skill-service';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useKnowledgeBaseId } from './use-knowledge-request';
@@ -51,3 +53,56 @@ export function useFetchDatasetSkillPage(skillKwd: string | null | undefined) {
return { data, loading };
}
export function useDeleteDatasetSkillTree() {
const kbId = useKnowledgeBaseId();
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationFn: async () => {
const { data } = await datasetSkillService.deleteTree({
datasetId: kbId,
});
if (data?.code === 0) {
message.success(i18n.t('message.deleted'));
queryClient.invalidateQueries({
queryKey: DatasetSkillKeys.all(kbId),
});
}
return data;
},
});
return { data, loading, deleteSkillTree: mutateAsync };
}
export function useDeleteDatasetSkillPage() {
const kbId = useKnowledgeBaseId();
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationFn: async (skillKwd: string) => {
const { data } = await datasetSkillService.deletePage({
datasetId: kbId,
skillKwd,
});
if (data?.code === 0) {
message.success(i18n.t('message.deleted'));
queryClient.invalidateQueries({
queryKey: DatasetSkillKeys.all(kbId),
});
}
return data;
},
});
return { data, loading, deleteSkillPage: mutateAsync };
}

View File

@@ -517,7 +517,6 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
graph: 'Graph',
graphPlaceholder: 'Graph view placeholder',
llmWiki: 'LLM Wiki',
skills: 'Skills',
contents: 'Navigation',
topics: 'Topics',
concept: 'Concept',
@@ -1013,7 +1012,7 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
},
representationUnsupported:
'This representation type is not supported yet.',
representationEmpty: 'No representation templates available.',
representationEmpty: 'No artifact templates available.',
enable: 'Enable',
disable: 'Disable',
delete: 'Delete',
@@ -2066,6 +2065,11 @@ Example: Virtual Hosted Style`,
selectFolder: 'Select a skill to view details',
currentFolder: 'Skill',
noContent: 'No content',
deleteAllTitle: 'Delete all skills',
deleteAllDescription:
'Are you sure you want to delete all skills? This action cannot be undone.',
deleteSkillTitle: 'Delete skill',
deleteSkillDescription: 'Are you sure you want to delete this skill?',
},
message: {
registered: 'Registered!',

View File

@@ -463,7 +463,6 @@ export default {
graph: '图谱',
graphPlaceholder: '图谱视图占位',
llmWiki: 'LLM Wiki',
skills: '技能',
contents: '导航',
topics: '主题',
concept: '概念',
@@ -914,7 +913,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
raptor: 'RAPTOR',
},
representationUnsupported: '暂不支持该表征类型。',
representationEmpty: '暂无表征模板。',
representationEmpty: '暂无Artifact模板。',
enable: '启用',
disable: '禁用',
delete: '删除',
@@ -1728,6 +1727,10 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
selectFolder: '选择一个技能以查看详情',
currentFolder: '技能',
noContent: '无内容',
deleteAllTitle: '删除全部技能',
deleteAllDescription: '确定要删除全部技能吗?此操作无法撤销。',
deleteSkillTitle: '删除技能',
deleteSkillDescription: '确定要删除该技能吗?',
},
message: {
registered: '注册成功',

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { gotoVSCode, Inspector } from 'react-dev-inspector';
import { Inspector } from 'react-dev-inspector';
import ReactDOM from 'react-dom/client';
import '../tailwind.css';
import App from './app';
@@ -9,7 +9,7 @@ import { initLanguage } from './locales/config';
initLanguage().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Inspector keys={['alt', 'c']} onInspectElement={gotoVSCode} />
<Inspector keys={['alt', 'c']} />
<App />
</React.StrictMode>,
);

View File

@@ -173,9 +173,11 @@ export function PipelineAccordionOperators({
...restrictSingleOperatorOnCanvas([Operator.Parser, Operator.Tokenizer]),
];
list.push(Operator.Extractor);
list.push(Operator.Compilation);
if (getOperatorTypeFromId(nodeId) !== Operator.Compilation) {
list.push(Operator.Compilation);
}
return list;
}, [restrictSingleOperatorOnCanvas]);
}, [getOperatorTypeFromId, nodeId, restrictSingleOperatorOnCanvas]);
const chunkerOperators = useMemo(() => {
return [

View File

@@ -16,7 +16,7 @@ import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
export const FormSchema = z.object({
compilation_template_group_ids: z.array(z.string()).optional(),
compilation_template_group_ids: z.string().optional(),
...LlmSettingSchema,
});

View File

@@ -70,7 +70,7 @@ export default function DocumentViewSwitch({
label: (
<div className="flex items-center gap-1">
<LayoutList className="h-4 w-4" />
<span>{t('chunk.representation', 'Representation')}</span>
<span>Artifact</span>
</div>
),
},

View File

@@ -1,5 +1,4 @@
export enum ViewMode {
Graph = 'graph',
LlmWiki = 'llm-wiki',
Skills = 'skills',
}

View File

@@ -1,67 +0,0 @@
import { DatasetSkillKeys } from '@/hooks/use-dataset-skill-request';
import { ArtifactKeys, ArtifactTopicKeys } from '@/hooks/use-knowledge-request';
import { useTraceGenerate } 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, useState } from 'react';
import { useParams } from 'react-router';
import { LeftPanelTab, ViewMode } from '../constants';
export function useCompilationView() {
const { id } = useParams();
const queryClient = useQueryClient();
const [leftTab, setLeftTab] = useState<LeftPanelTab>(LeftPanelTab.Contents);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.LlmWiki);
const { artifactRunData, skillRunData } = useTraceGenerate({ open: true });
const { status: artifactStatus } = useGenerateStatus(artifactRunData);
const { status: skillStatus } = useGenerateStatus(skillRunData);
useEffect(() => {
if (viewMode === ViewMode.LlmWiki && artifactStatus === 'completed') {
queryClient.invalidateQueries({
queryKey: ArtifactKeys.listByDataset(id!),
});
queryClient.invalidateQueries({
queryKey: ArtifactTopicKeys.listByDataset(id!),
});
}
}, [viewMode, artifactStatus, queryClient, id]);
useEffect(() => {
if (viewMode === ViewMode.Skills && skillStatus === 'completed') {
queryClient.invalidateQueries({
queryKey: DatasetSkillKeys.tree(id!),
});
}
}, [viewMode, skillStatus, queryClient, id]);
const handleSwitchToGraph = useCallback(() => {
setViewMode(ViewMode.Graph);
}, []);
const handleSwitchToLlmWiki = useCallback(() => {
setViewMode(ViewMode.LlmWiki);
}, []);
const handleSwitchToSkills = useCallback(() => {
setViewMode(ViewMode.Skills);
}, []);
const handleLeftTabChange = useCallback((value: string) => {
setLeftTab(value as LeftPanelTab);
}, []);
return {
leftTab,
viewMode,
artifactRunData,
skillRunData,
artifactStatus,
skillStatus,
handleSwitchToGraph,
handleSwitchToLlmWiki,
handleSwitchToSkills,
handleLeftTabChange,
};
}

View File

@@ -1,83 +1,30 @@
import BackButton from '@/components/back-button';
import MarkdownEditor from '@/components/markdown-editor';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { SkeletonCard } from '@/components/skeleton-card';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import {
useFetchArtifactTopicList,
useFetchKnowledgeBaseConfiguration,
useFetchKnowledgeGraph,
} from '@/hooks/use-knowledge-request';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { ViewMode } from './constants';
import CompilationEmptyState from './empty-state';
import { useCompilationArtifact } from './hooks/use-compilation-artifact';
import { useCompilationSkill } from './hooks/use-compilation-skill';
import { useCompilationView } from './hooks/use-compilation-view';
import KnowledgeForceGraph from './knowledge-force-graph';
import { SkillsLeftPanel } from './skills-left-panel';
import { WikiDetailContent } from './wiki-detail-content';
import { WikiLeftPanel } from './wiki-left-panel';
import { LlmWikiView } from './llm-wiki-view';
import { SkillsView } from './skills-view';
export default function Compilation() {
const { t } = useTranslation();
const { id } = useParams();
const { navigateToDataFile } = useNavigatePage();
const { data: knowledgeBase } = useFetchKnowledgeBaseConfiguration();
const { data: knowledgeGraph, loading: knowledgeGraphLoading } =
useFetchKnowledgeGraph();
const { topics, loading: topicListLoading } = useFetchArtifactTopicList();
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.LlmWiki);
const {
leftTab,
viewMode,
artifactRunData,
skillRunData,
handleSwitchToLlmWiki,
handleSwitchToSkills,
handleLeftTabChange,
} = useCompilationView();
const handleSwitchToLlmWiki = useCallback(() => {
setViewMode(ViewMode.LlmWiki);
}, []);
const {
selectedArtifact,
selectedVersion,
selectVersion,
handleSelectArtifact,
clearSelectedArtifact,
} = useCompilationArtifact();
const {
selectedSkill,
setSelectedSkill,
skillTree,
skillTreeLoading,
skillPage,
} = useCompilationSkill();
const isLlmWikiEmpty = topics.length === 0 && !topicListLoading;
const canGenerate = (knowledgeBase?.chunk_count ?? 0) > 0;
const isGraphLoading = knowledgeGraphLoading && !knowledgeGraph?.graph;
const isLlmWikiLoading = topicListLoading && topics.length === 0;
const isSkillsLoading =
skillTreeLoading && !skillTree?.skill_with_weight?.length;
const isSkillsEmpty =
!skillTree?.skill_with_weight?.length && !skillTreeLoading;
const loadingCard = (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col p-8">
<SkeletonCard className="flex-1" />
</Card>
);
const handleSwitchToSkills = useCallback(() => {
setViewMode(ViewMode.Skills);
}, []);
return (
<section className="flex flex-col p-4 gap-4 h-full">
@@ -112,80 +59,13 @@ export default function Compilation() {
size="sm"
onClick={handleSwitchToSkills}
>
{t('knowledgeDetails.skills')}
To Skills
</Button>
</div>
</section>
</header>
{viewMode === ViewMode.Graph ? (
isGraphLoading ? (
loadingCard
) : (
<div className="flex-1 min-h-0 flex flex-col">
<KnowledgeForceGraph data={knowledgeGraph?.graph} show />
</div>
)
) : viewMode === ViewMode.LlmWiki ? (
isLlmWikiLoading ? (
loadingCard
) : isLlmWikiEmpty ? (
<CompilationEmptyState
type="llm-wiki"
disabled={!canGenerate}
data={artifactRunData}
/>
) : (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={33} minSize={20} maxSize={50}>
<WikiLeftPanel
tab={leftTab}
onTabChange={handleLeftTabChange}
selectedArtifact={selectedArtifact}
onSelectArtifact={handleSelectArtifact}
onClearWiki={clearSelectedArtifact}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel>
<WikiDetailContent
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onSelectVersion={selectVersion}
onSelectArtifact={handleSelectArtifact}
/>
</ResizablePanel>
</ResizablePanelGroup>
</Card>
)
) : isSkillsLoading ? (
loadingCard
) : isSkillsEmpty ? (
<CompilationEmptyState
type="skills"
disabled={!canGenerate}
data={skillRunData}
/>
) : (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={33} minSize={20} maxSize={50}>
<SkillsLeftPanel
selectedSkill={selectedSkill}
onSelectSkill={setSelectedSkill}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel className="flex flex-col">
<MarkdownEditor
content={skillPage?.md_with_weight ?? ''}
readOnly
/>
</ResizablePanel>
</ResizablePanelGroup>
</Card>
)}
{viewMode === ViewMode.LlmWiki ? <LlmWikiView /> : <SkillsView />}
</section>
);
}

View File

@@ -1,347 +0,0 @@
import { useIsDarkTheme } from '@/components/theme-provider';
import { cn } from '@/lib/utils';
import isEmpty from 'lodash/isEmpty';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ForceGraph2D, { LinkObject, NodeObject } from 'react-force-graph-2d';
import { buildNodesAndCombos } from '../knowledge-graph/util';
interface IProps {
data: any;
show: boolean;
}
interface NodeData {
rank?: number;
entity_type?: string;
description?: string;
weight?: number;
communities?: string[];
combo?: string;
__bckgDimensions?: [number, number];
}
interface LinkData {
weight?: number;
}
type GNode = NodeObject<NodeData>;
type GLink = LinkObject<NodeData, LinkData>;
const NodeColorPalette = [
'#5B8FF9',
'#5AD8A6',
'#F6BD16',
'#E8684A',
'#6DC8EC',
'#9270CA',
'#FF9D4D',
'#269A99',
'#FF99C3',
'#A9A9A9',
];
const getNodeSize = (node: GNode) => {
const size = 180 + ((node.rank as number) || 0) * 5;
return Math.min(size, 300) / 2;
};
const getLinkWidth = (link: GLink) => {
const weight = Number(link.weight) || 2;
return Math.min(weight * 4, 8);
};
export function KnowledgeForceGraph({ data, show }: IProps) {
const containerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);
const isDark = useIsDarkTheme();
const [hoverNode, setHoverNode] = useState<GNode | null>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const forceConfiguredRef = useRef(false);
const graphData = useMemo(() => {
if (isEmpty(data)) {
return { nodes: [], links: [] };
}
const rawNodes = data.nodes || [];
const rawEdges = data.edges || [];
const { nodes: comboNodes } = buildNodesAndCombos(rawNodes);
const entityTypes = Array.from(
new Set(rawNodes.map((n: any) => n.entity_type).filter(Boolean)),
);
const typeColorMap = entityTypes.reduce<Record<string, string>>(
(pre, cur, idx) => {
pre[cur as string] = NodeColorPalette[idx % NodeColorPalette.length];
return pre;
},
{},
);
const nodes = comboNodes.map((n: any) => ({
...n,
color: typeColorMap[n.entity_type as string] || NodeColorPalette[0],
}));
const links = rawEdges.map((e: any) => ({
source: e.source,
target: e.target,
weight: e.weight,
}));
return { nodes, links };
}, [data]);
// Compute the set of node IDs directly connected to the hovered node (degree: 1)
const connectedNodeIds = useMemo(() => {
if (!hoverNode) return new Set<string>();
const ids = new Set<string>();
ids.add(hoverNode.id as string);
graphData.links.forEach((link: any) => {
const sourceId =
typeof link.source === 'object' ? link.source.id : link.source;
const targetId =
typeof link.target === 'object' ? link.target.id : link.target;
if (sourceId === hoverNode.id) ids.add(targetId as string);
if (targetId === hoverNode.id) ids.add(sourceId as string);
});
return ids;
}, [hoverNode, graphData.links]);
useEffect(() => {
if (!containerRef.current || !show) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const { width, height } = entry.contentRect;
setDimensions({ width, height });
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [show]);
// Reset the flag when graphData changes, so forces are reconfigured for new data
useEffect(() => {
forceConfiguredRef.current = false;
}, [graphData]);
useEffect(() => {
if (
fgRef.current &&
!isEmpty(graphData.nodes) &&
!forceConfiguredRef.current
) {
forceConfiguredRef.current = true;
// D3 force configuration
const linkForce = fgRef.current.d3Force('link');
if (linkForce) {
linkForce.distance((link: GLink) => {
const source =
typeof link.source === 'object'
? (link.source as GNode)
: (graphData.nodes.find(
(n: GNode) => n.id === link.source,
) as GNode);
const target =
typeof link.target === 'object'
? (link.target as GNode)
: (graphData.nodes.find(
(n: GNode) => n.id === link.target,
) as GNode);
const sourceSize = source ? getNodeSize(source) : 16;
const targetSize = target ? getNodeSize(target) : 16;
return sourceSize + targetSize + 200;
});
}
const chargeForce = fgRef.current.d3Force('charge');
if (chargeForce) {
chargeForce.strength(-1000);
}
const timer = setTimeout(() => {
fgRef.current?.zoomToFit(400, 50);
}, 600);
return () => clearTimeout(timer);
}
}, [graphData, dimensions]);
const getNodeLabel = useCallback((node: GNode) => {
const parts = [`<h3 class="font-semibold">${node.id}</h3>`];
if (node.entity_type) {
parts.push(
`<div class="flex items-center gap-[.5ch]"><dt><b>Entity type: </b></dt><dd>${node.entity_type}</dd></div>`,
);
}
if (node.weight) {
parts.push(
`<div class="flex items-center gap-[.5ch]"><dt><b>Weight: </b></dt><dd>${node.weight}</dd></div>`,
);
}
if (node.description) {
parts.push(
`<p class="text-xs mt-1 max-w-[240px]">${node.description}</p>`,
);
}
return `<dl class="mb-1 empty:hidden">${parts.join('')}</dl>`;
}, []);
const getLinkLabel = useCallback((link: GLink) => {
const sourceId =
typeof link.source === 'object' ? link.source.id : link.source;
const targetId =
typeof link.target === 'object' ? link.target.id : link.target;
return `${sourceId}${targetId}`;
}, []);
const nodeCanvasObject = useCallback(
(node: GNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const size = getNodeSize(node);
const label = (node.id as string) || '';
const fontSize = Math.max(12 / globalScale, Math.min(size / 2, 40));
// Dim non-connected nodes when hovering
const isDimmed = hoverNode && !connectedNodeIds.has(node.id as string);
ctx.globalAlpha = isDimmed ? 0.1 : 1;
// Node circle (solid fill by entity_type color)
const nodeColor = (node as any).color || NodeColorPalette[0];
ctx.beginPath();
ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor;
ctx.fill();
ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)';
ctx.lineWidth = Math.max(2 / globalScale, 3);
ctx.stroke();
// Only show label on hover
if (hoverNode?.id === node.id) {
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const padding = fontSize * 0.4;
const bckgDimensions: [number, number] = [
textWidth + padding * 2,
fontSize + padding * 2,
];
// Label background
ctx.fillStyle = isDark
? 'rgba(0, 0, 0, 0.1)'
: 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(
node.x! - bckgDimensions[0] / 2,
node.y! + size + fontSize / 2,
bckgDimensions[0],
bckgDimensions[1],
);
// Label text
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = isDark ? '#fff' : '#000';
ctx.fillText(label, node.x!, node.y! + size + fontSize);
(node as any).__bckgDimensions = bckgDimensions;
}
ctx.globalAlpha = 1;
},
[isDark, hoverNode, connectedNodeIds],
);
const nodePointerAreaPaint = useCallback(
(node: GNode, color: string, ctx: CanvasRenderingContext2D) => {
const size = getNodeSize(node);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI);
ctx.fill();
},
[],
);
const linkCanvasObject = useCallback(
(link: GLink, ctx: CanvasRenderingContext2D) => {
const source =
typeof link.source === 'object'
? (link.source as GNode)
: (graphData.nodes.find((n: GNode) => n.id === link.source) as GNode);
const target =
typeof link.target === 'object'
? (link.target as GNode)
: (graphData.nodes.find((n: GNode) => n.id === link.target) as GNode);
if (!source || !target) return;
const isDimmed =
hoverNode &&
!(
connectedNodeIds.has(source.id as string) &&
connectedNodeIds.has(target.id as string)
);
ctx.globalAlpha = isDimmed ? 0.1 : 1;
const lineWidth = getLinkWidth(link);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)';
ctx.beginPath();
ctx.moveTo(source.x!, source.y!);
ctx.lineTo(target.x!, target.y!);
ctx.stroke();
ctx.globalAlpha = 1;
},
[graphData.nodes, hoverNode, connectedNodeIds, isDark],
);
const handleNodeHover = useCallback((node: GNode | null) => {
setHoverNode(node);
}, []);
const backgroundColor = isDark ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)';
return (
<div
ref={containerRef}
className={cn('flex-1 min-h-0', !show && 'hidden')}
aria-haspopup="true"
>
{dimensions.width > 0 && dimensions.height > 0 && (
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
backgroundColor={backgroundColor}
nodeLabel={getNodeLabel}
linkLabel={getLinkLabel}
nodeRelSize={1}
nodeCanvasObject={nodeCanvasObject}
nodePointerAreaPaint={nodePointerAreaPaint}
linkCanvasObject={linkCanvasObject}
linkWidth={getLinkWidth}
linkColor={() =>
isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
}
onNodeHover={handleNodeHover}
enableNodeDrag
enableZoomInteraction
enablePanInteraction
warmupTicks={50}
cooldownTicks={100}
/>
)}
</div>
);
}
export default KnowledgeForceGraph;

View File

@@ -0,0 +1,104 @@
import { Card } from '@/components/ui/card';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import {
ArtifactKeys,
ArtifactTopicKeys,
useFetchArtifactTopicList,
useFetchKnowledgeBaseConfiguration,
} from '@/hooks/use-knowledge-request';
import {
GenerateStatus,
GenerateType,
} 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, useState } from 'react';
import { useParams } from 'react-router';
import { LeftPanelTab } from './constants';
import CompilationEmptyState from './empty-state';
import { useCompilationArtifact } from './hooks/use-compilation-artifact';
import { CompilationLoadingCard } from './loading-card';
import { WikiDetailContent } from './wiki-detail-content';
import { WikiLeftPanel } from './wiki-left-panel';
export function LlmWikiView() {
const { id } = useParams();
const queryClient = useQueryClient();
const [leftTab, setLeftTab] = useState<LeftPanelTab>(LeftPanelTab.Contents);
const { data: knowledgeBase } = useFetchKnowledgeBaseConfiguration();
const { topics, loading: topicListLoading } = useFetchArtifactTopicList();
const {
selectedArtifact,
selectedVersion,
selectVersion,
handleSelectArtifact,
clearSelectedArtifact,
} = useCompilationArtifact();
const { data: artifactRunData } = useTraceRunData(GenerateType.Artifact);
const { status: artifactStatus } = useGenerateStatus(artifactRunData);
useEffect(() => {
if (artifactStatus === GenerateStatus.completed) {
queryClient.invalidateQueries({
queryKey: ArtifactKeys.listByDataset(id!),
});
queryClient.invalidateQueries({
queryKey: ArtifactTopicKeys.listByDataset(id!),
});
}
}, [artifactStatus, queryClient, id]);
const handleLeftTabChange = useCallback((value: string) => {
setLeftTab(value as LeftPanelTab);
}, []);
const canGenerate = (knowledgeBase?.chunk_count ?? 0) > 0;
const isLoading = topicListLoading && topics.length === 0;
const isEmpty = topics.length === 0 && !topicListLoading;
if (isLoading) {
return <CompilationLoadingCard />;
}
if (isEmpty) {
return (
<CompilationEmptyState
type="llm-wiki"
disabled={!canGenerate}
data={artifactRunData}
/>
);
}
return (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={33} minSize={20} maxSize={50}>
<WikiLeftPanel
tab={leftTab}
onTabChange={handleLeftTabChange}
selectedArtifact={selectedArtifact}
onSelectArtifact={handleSelectArtifact}
onClearWiki={clearSelectedArtifact}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel>
<WikiDetailContent
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onSelectVersion={selectVersion}
onSelectArtifact={handleSelectArtifact}
/>
</ResizablePanel>
</ResizablePanelGroup>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { SkeletonCard } from '@/components/skeleton-card';
import { Card } from '@/components/ui/card';
export function CompilationLoadingCard() {
return (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col p-8">
<SkeletonCard className="flex-1" />
</Card>
);
}

View File

@@ -1,61 +1,79 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input';
import { Spin } from '@/components/ui/spin';
import { useFetchDatasetSkillTree } from '@/hooks/use-dataset-skill-request';
import { DatasetSkillTreeNode } from '@/interfaces/database/dataset-skill';
import { cn } from '@/lib/utils';
import { TreeDataItem, TreeView } from '@/components/ui/tree-view';
import {
useDeleteDatasetSkillPage,
useDeleteDatasetSkillTree,
useFetchDatasetSkillTree,
} from '@/hooks/use-dataset-skill-request';
import { useDebounce } from 'ahooks';
import { FileText, Folder, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
buildSkillTreeData,
countSkillTreeNodes,
filterSkillTreeData,
} from './utils/skill-tree';
type FlatSkillNode = {
skill_kwd: string;
depth: number;
};
// TreeView only computes expandedItemIds when initialSelectedItemId is
// truthy; combined with expandAll, any truthy id makes every branch mount
// open. A sentinel that matches no real skill_kwd forces expand-all without
// highlighting any row as selected.
const ExpandAllSentinelId = '__skill-tree-expand-all-sentinel__';
type SkillsLeftPanelProps = {
selectedSkill: string | null;
onSelectSkill: (skillKwd: string) => void;
onSelectSkill: (skillKwd: string | null) => void;
};
function flattenSkillTree(nodes: DatasetSkillTreeNode[] = []): FlatSkillNode[] {
const result: FlatSkillNode[] = [];
function walk(items: DatasetSkillTreeNode[], depth: number) {
for (const item of items) {
result.push({ skill_kwd: item.skill_kwd, depth });
if (item.children_kwd?.length) {
walk(item.children_kwd, depth + 1);
}
}
}
walk(nodes, 0);
return result;
}
type SkillListItemProps = {
item: FlatSkillNode;
isSelected: boolean;
onSelect: (skillKwd: string) => void;
type SkillDeleteActionProps = {
skillKwd: string;
deleteLoading: boolean;
onDelete: (skillKwd: string) => void;
};
function SkillListItem({ item, isSelected, onSelect }: SkillListItemProps) {
const handleClick = useCallback(() => {
onSelect(item.skill_kwd);
}, [item.skill_kwd, onSelect]);
function SkillDeleteAction({
skillKwd,
deleteLoading,
onDelete,
}: SkillDeleteActionProps) {
const { t } = useTranslation();
const handleTriggerClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
// TreeView does not guard action clicks: without this the row would
// also get selected and a branch row would toggle its accordion.
e.stopPropagation();
},
[],
);
const handleConfirmDelete = useCallback(() => {
onDelete(skillKwd);
}, [skillKwd, onDelete]);
return (
<li
onClick={handleClick}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm cursor-pointer',
'text-text-secondary hover:bg-bg-base hover:text-text-primary',
isSelected && 'bg-bg-card text-text-primary',
)}
style={{ paddingLeft: `${item.depth * 16 + 12}px` }}
<ConfirmDeleteDialog
title={t('datasetSkill.deleteSkillTitle')}
content={{ title: t('datasetSkill.deleteSkillDescription') }}
onOk={handleConfirmDelete}
>
<span className="truncate">{item.skill_kwd}</span>
</li>
<Button
variant="ghost"
size="icon-sm"
disabled={deleteLoading}
onClick={handleTriggerClick}
// TreeActions keeps actions always visible on the selected row;
// hide again so the button only appears while hovering the row.
// `hidden` (not opacity-0) so no invisible click target remains.
className="hidden group-hover:inline-flex"
>
<Trash2 />
</Button>
</ConfirmDeleteDialog>
);
}
@@ -65,22 +83,18 @@ export function SkillsLeftPanel({
}: SkillsLeftPanelProps) {
const { t } = useTranslation();
const { data: tree, loading } = useFetchDatasetSkillTree();
const { deleteSkillTree, loading: deleteTreeLoading } =
useDeleteDatasetSkillTree();
const { deleteSkillPage, loading: deletePageLoading } =
useDeleteDatasetSkillPage();
const [searchString, setSearchString] = useState('');
const debouncedSearchString = useDebounce(searchString, { wait: 500 });
const allNodes = useMemo(
() => flattenSkillTree(tree?.skill_with_weight),
const totalCount = useMemo(
() => countSkillTreeNodes(tree?.skill_with_weight),
[tree?.skill_with_weight],
);
const filteredNodes = useMemo(() => {
const keyword = debouncedSearchString.trim().toLowerCase();
if (!keyword) return allNodes;
return allNodes.filter((node) =>
node.skill_kwd.toLowerCase().includes(keyword),
);
}, [allNodes, debouncedSearchString]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchString(e.target.value);
@@ -88,8 +102,73 @@ export function SkillsLeftPanel({
[],
);
const handleDeleteAll = useCallback(async () => {
const data = await deleteSkillTree();
if (data?.code === 0) {
onSelectSkill(null);
}
}, [deleteSkillTree, onSelectSkill]);
const handleDeleteSkill = useCallback(
async (skillKwd: string) => {
const data = await deleteSkillPage(skillKwd);
if (data?.code === 0 && selectedSkill === skillKwd) {
onSelectSkill(null);
}
},
[deleteSkillPage, selectedSkill, onSelectSkill],
);
const renderSkillActions = useCallback(
(skillKwd: string) => (
<SkillDeleteAction
skillKwd={skillKwd}
deleteLoading={deletePageLoading}
onDelete={handleDeleteSkill}
/>
),
[deletePageLoading, handleDeleteSkill],
);
const treeData = useMemo(
() => buildSkillTreeData(tree?.skill_with_weight, renderSkillActions),
[tree?.skill_with_weight, renderSkillActions],
);
const filteredTreeData = useMemo(
() => filterSkillTreeData(treeData, debouncedSearchString),
[treeData, debouncedSearchString],
);
const handleTreeSelect = useCallback(
(item: TreeDataItem | undefined) => {
onSelectSkill(item?.id ?? null);
},
[onSelectSkill],
);
return (
<aside className="size-full flex flex-col">
<section className="flex items-center justify-between px-3 pt-3">
<span className="text-sm font-medium text-text-primary">
{t('datasetSkill.folders')} ({totalCount})
</span>
<ConfirmDeleteDialog
title={t('datasetSkill.deleteAllTitle')}
content={{ title: t('datasetSkill.deleteAllDescription') }}
onOk={handleDeleteAll}
>
<Button
variant="ghost"
size="icon-sm"
disabled={deleteTreeLoading}
data-testid="skills-clear-trigger"
>
<Trash2 />
</Button>
</ConfirmDeleteDialog>
</section>
<div className="px-3 py-2">
<SearchInput
placeholder={t('common.search')}
@@ -98,28 +177,26 @@ export function SkillsLeftPanel({
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading && filteredNodes.length === 0 ? (
<div className="flex-1 min-h-0 overflow-y-auto px-1 pb-3">
{loading && filteredTreeData.length === 0 ? (
<div className="py-8 flex justify-center">
<Spin size="small" />
</div>
) : filteredNodes.length === 0 ? (
) : filteredTreeData.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
{debouncedSearchString
? t('common.noData')
: t('datasetSkill.empty')}
</div>
) : (
<ul className="space-y-1">
{filteredNodes.map((item) => (
<SkillListItem
key={item.skill_kwd}
item={item}
isSelected={selectedSkill === item.skill_kwd}
onSelect={onSelectSkill}
/>
))}
</ul>
<TreeView
data={filteredTreeData}
initialSelectedItemId={ExpandAllSentinelId}
onSelectChange={handleTreeSelect}
expandAll
defaultNodeIcon={Folder}
defaultLeafIcon={FileText}
/>
)}
</div>
</aside>

View File

@@ -0,0 +1,82 @@
import MarkdownEditor from '@/components/markdown-editor';
import { Card } from '@/components/ui/card';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { DatasetSkillKeys } from '@/hooks/use-dataset-skill-request';
import { useFetchKnowledgeBaseConfiguration } from '@/hooks/use-knowledge-request';
import {
GenerateStatus,
GenerateType,
} 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 { useEffect } from 'react';
import { useParams } from 'react-router';
import CompilationEmptyState from './empty-state';
import { useCompilationSkill } from './hooks/use-compilation-skill';
import { CompilationLoadingCard } from './loading-card';
import { SkillsLeftPanel } from './skills-left-panel';
export function SkillsView() {
const { id } = useParams();
const queryClient = useQueryClient();
const { data: knowledgeBase } = useFetchKnowledgeBaseConfiguration();
const {
selectedSkill,
setSelectedSkill,
skillTree,
skillTreeLoading,
skillPage,
} = useCompilationSkill();
const { data: skillRunData } = useTraceRunData(GenerateType.ToSkills);
const { status: skillStatus } = useGenerateStatus(skillRunData);
useEffect(() => {
if (skillStatus === GenerateStatus.completed) {
queryClient.invalidateQueries({
queryKey: DatasetSkillKeys.tree(id!),
});
}
}, [skillStatus, queryClient, id]);
const canGenerate = (knowledgeBase?.chunk_count ?? 0) > 0;
const isLoading = skillTreeLoading && !skillTree?.skill_with_weight?.length;
const isEmpty = !skillTree?.skill_with_weight?.length && !skillTreeLoading;
if (isLoading) {
return <CompilationLoadingCard />;
}
if (isEmpty) {
return (
<CompilationEmptyState
type="skills"
disabled={!canGenerate}
data={skillRunData}
/>
);
}
return (
<Card className="flex-1 min-h-0 overflow-hidden flex border-border-button rounded-xl flex-col">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={33} minSize={20} maxSize={50}>
<SkillsLeftPanel
selectedSkill={selectedSkill}
onSelectSkill={setSelectedSkill}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel className="flex flex-col">
<MarkdownEditor content={skillPage?.md_with_weight ?? ''} readOnly />
</ResizablePanel>
</ResizablePanelGroup>
</Card>
);
}

View File

@@ -0,0 +1,65 @@
import type { TreeDataItem } from '@/components/ui/tree-view';
import type { DatasetSkillTreeNode } from '@/interfaces/database/dataset-skill';
import type { ReactNode } from 'react';
export type SkillTreeActionsFactory = (skillKwd: string) => ReactNode;
export function buildSkillTreeData(
nodes: DatasetSkillTreeNode[] = [],
getActions?: SkillTreeActionsFactory,
): TreeDataItem[] {
return nodes.map((node) => {
const item: TreeDataItem = {
id: node.skill_kwd,
name: node.skill_kwd,
actions: getActions?.(node.skill_kwd),
};
// Only set children when non-empty: TreeView branches on
// `item.children ? TreeNode : TreeLeaf` and [] is truthy, so an empty
// array would render a leaf as a branch with a chevron and no children.
if (node.children_kwd?.length) {
item.children = buildSkillTreeData(node.children_kwd, getActions);
}
return item;
});
}
// Same prune logic as filterTreeDataByKeyword in
// pages/chunk/representation/utils/adapters.ts — that module carries
// chunk-specific TreeDataItem augmentation, so a feature-local copy is kept
// here instead of a cross-feature import.
export function filterSkillTreeData(
items: TreeDataItem[],
keyword: string,
): TreeDataItem[] {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return items;
}
return items.reduce<TreeDataItem[]>((acc, item) => {
const children = item.children
? filterSkillTreeData(item.children, keyword)
: [];
const matches = item.name.toLowerCase().includes(normalizedKeyword);
if (matches || children.length > 0) {
acc.push({
...item,
// A matched node keeps its full original subtree; an unmatched
// ancestor keeps only the pruned children leading to matches.
children: children.length > 0 ? children : item.children,
});
}
return acc;
}, []);
}
export function countSkillTreeNodes(
nodes: DatasetSkillTreeNode[] = [],
): number {
return nodes.reduce(
(total, node) => total + 1 + countSkillTreeNodes(node.children_kwd),
0,
);
}

View File

@@ -3,7 +3,6 @@ import {
AutoQuestionsFormField,
} from '@/components/auto-keywords-form-field';
import { ChildrenDelimiterForm } from '@/components/children-delimiter-form';
import { CompilationTemplateFormField } from '@/components/compilation-template-form-field';
import { DelimiterFormField } from '@/components/delimiter-form-field';
import { ExcelToHtmlFormField } from '@/components/excel-to-html-form-field';
import { LayoutRecognizeFormField } from '@/components/layout-recognize-form-field';
@@ -25,7 +24,6 @@ export function NaiveConfiguration() {
return (
<MainContainer>
<ConfigurationFormContainer>
<CompilationTemplateFormField horizontal></CompilationTemplateFormField>
<LayoutRecognizeFormField
testId="ds-settings-parser-pdf-parser-select"
ownerTenantId={ownerTenantId}

View File

@@ -92,7 +92,6 @@ export const formSchema = z
path: ['entity_types'],
},
),
compilation_template_group_id: z.array(z.string()).optional(),
metadata: z.any().optional(),
built_in_metadata: z
.array(

View File

@@ -105,7 +105,6 @@ export default function DatasetSettings() {
additionalProperties: false,
},
built_in_metadata: [],
compilation_template_group_id: [],
enable_metadata: false,
llm_id: '',
},

View File

@@ -69,40 +69,16 @@ const useTraceQuery = (
});
};
export const useTraceGenerate = ({ open }: { open: boolean }) => {
const TraceTypeMap: Record<GenerateType, TraceType> = {
[GenerateType.KnowledgeGraph]: TraceType.Graph,
[GenerateType.Raptor]: TraceType.Raptor,
[GenerateType.Artifact]: TraceType.Artifact,
[GenerateType.ToSkills]: TraceType.Skill,
};
export const useTraceRunData = (type: GenerateType) => {
const { id } = useParams();
const { data: graphRunData, isFetching: graphRunLoading } = useTraceQuery(
GenerateType.KnowledgeGraph,
TraceType.Graph,
open,
id,
);
const { data: raptorRunData, isFetching: raptorRunLoading } = useTraceQuery(
GenerateType.Raptor,
TraceType.Raptor,
open,
id,
);
const { data: artifactRunData, isFetching: artifactRunLoading } =
useTraceQuery(GenerateType.Artifact, TraceType.Artifact, open, id);
const { data: skillRunData, isFetching: skillRunLoading } = useTraceQuery(
GenerateType.ToSkills,
TraceType.Skill,
open,
id,
);
return {
graphRunData,
graphRunLoading,
raptorRunData,
raptorRunLoading,
artifactRunData,
artifactRunLoading,
skillRunData,
skillRunLoading,
};
return useTraceQuery(type, TraceTypeMap[type], true, id);
};
export const useUnBindTask = () => {
@@ -144,15 +120,7 @@ export const useDatasetGenerate = () => {
} = useMutation({
mutationKey: [DatasetKey.generate],
mutationFn: async ({ type }: { type: GenerateType }) => {
const indexType =
type === GenerateType.KnowledgeGraph
? TraceType.Graph
: type === GenerateType.Artifact
? TraceType.Artifact
: type === GenerateType.ToSkills
? TraceType.Skill
: TraceType.Raptor;
const { data } = await runIndex(id!, indexType);
const { data } = await runIndex(id!, TraceTypeMap[type]);
if (data.code === 0) {
message.success(t('message.operated'));
queryClient.invalidateQueries({

View File

@@ -68,7 +68,7 @@ export function SideBar({ dataset: data }: PropType) {
: []),
{
icon: <LucideBookText className="size-[1em]" />,
label: t(`knowledgeDetails.compilation`),
label: 'Artifacts',
key: Routes.Compilation,
},
];

View File

@@ -96,6 +96,7 @@ export function AddFieldModal({
handleTypeChange(value);
}}
placeholder={t('setting.selectFieldType')}
allowCustomValue
/>
)}
</RAGFlowFormItem>
@@ -105,7 +106,8 @@ export function AddFieldModal({
<RAGFlowFormItem key={key} name={key} label={getFieldLabel(key)}>
<Textarea
placeholder={t('setting.descriptionPlaceholder')}
rows={3}
rows={key === 'description' ? 4 : 10}
resize="vertical"
/>
</RAGFlowFormItem>
))}

View File

@@ -23,7 +23,11 @@ export function BasicInfoStep() {
label={t('setting.groupDescription')}
horizontal
>
<Textarea placeholder={t('common.descriptionPlaceholder')} rows={3} />
<Textarea
placeholder={t('common.descriptionPlaceholder')}
rows={3}
resize="vertical"
/>
</RAGFlowFormItem>
<RAGFlowFormItem name="avatar" label={t('setting.avatar')} horizontal>

View File

@@ -1,25 +1,14 @@
import { Card, CardContent } from '@/components/ui/card';
import { ICompilationTemplateSection } from '@/interfaces/database/compilation-template';
import { cn } from '@/lib/utils';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import {
ArrayPath,
useFieldArray,
useFormContext,
useWatch,
} from 'react-hook-form';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FormSchemaType } from '@/pages/user-setting/compilation-templates/create-next/schema';
import { getTypeOptionsFromBuiltinSection } from '@/pages/user-setting/compilation-templates/create-next/utils';
import { FieldCard } from './field-card';
type SectionFieldGridProps = {
fieldsPath: ArrayPath<FormSchemaType>;
fieldsPath: string;
sectionName: string;
builtinSection?: ICompilationTemplateSection;
onOpenAddField: () => void;
onEditField: (index: number) => void;
};
@@ -27,12 +16,11 @@ type SectionFieldGridProps = {
export function SectionFieldGrid({
fieldsPath,
sectionName,
builtinSection,
onOpenAddField,
onEditField,
}: SectionFieldGridProps) {
const { t } = useTranslation();
const form = useFormContext<FormSchemaType>();
const form = useFormContext();
const { fields, remove } = useFieldArray({
control: form.control,
name: fieldsPath,
@@ -40,29 +28,11 @@ export function SectionFieldGrid({
const isTypedSection = sectionName === 'entity' || sectionName === 'relation';
const availableTypes = useMemo(
() => getTypeOptionsFromBuiltinSection(builtinSection),
[builtinSection],
);
const currentFields = useWatch({
control: form.control,
name: fieldsPath,
}) as Record<string, string>[] | undefined;
const allTypesUsed = useMemo(() => {
if (!isTypedSection || availableTypes.length === 0) return false;
const availableSet = new Set(availableTypes.map((type) => type.value));
const usedAvailableTypes = new Set(
(currentFields ?? [])
.map((field) => field.type)
.filter(
(type): type is string => Boolean(type) && availableSet.has(type),
),
);
return usedAvailableTypes.size >= availableTypes.length;
}, [isTypedSection, availableTypes, currentFields]);
return (
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{fields.map((field, index) => {
@@ -78,27 +48,25 @@ export function SectionFieldGrid({
);
})}
{!allTypesUsed && (
<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>
)}
<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

@@ -116,7 +116,6 @@ export function TemplateConfiguration({
key={activeFieldsPath}
fieldsPath={activeFieldsPath}
sectionName={sectionName}
builtinSection={builtinSection}
onOpenAddField={handleOpenAddField}
onEditField={handleOpenEditField}
/>
@@ -126,7 +125,6 @@ export function TemplateConfiguration({
[
activeFieldsPath,
activeSectionTab,
builtinSection,
handleOpenAddField,
handleOpenEditField,
],
@@ -147,6 +145,7 @@ export function TemplateConfiguration({
<RAGFlowFormItem
name={`templates.${selectedTemplateIndex}.name`}
label={t('setting.templateName')}
required
>
<Input placeholder={t('common.namePlaceholder')} />
</RAGFlowFormItem>
@@ -158,6 +157,7 @@ export function TemplateConfiguration({
<Textarea
placeholder={t('common.descriptionPlaceholder')}
rows={2}
resize="vertical"
/>
</RAGFlowFormItem>
@@ -189,7 +189,8 @@ export function TemplateConfiguration({
>
<Textarea
placeholder={t('setting.globalRulesPlaceholder')}
rows={4}
rows={8}
resize="vertical"
/>
</RAGFlowFormItem>

View File

@@ -28,7 +28,7 @@ export const buildSynthesisSchema = () =>
export const buildTemplateSchema = (t: (key: string) => string) =>
z.object({
id: z.string().optional(),
name: 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')),

View File

@@ -6,6 +6,12 @@ const datasetSkillService = {
request.get(api.getDatasetSkillTree(params.datasetId)),
getPage: (params: { datasetId: string; skillKwd: string }) =>
request.get(api.getDatasetSkillPage(params.datasetId, params.skillKwd)),
deleteTree: (params: { datasetId: string }) =>
request.delete(api.deleteDatasetSkillTree(params.datasetId)),
deletePage: (params: { datasetId: string; skillKwd: string }) =>
request.delete(
api.deleteDatasetSkillPage(params.datasetId, params.skillKwd),
),
};
export default datasetSkillService;

View File

@@ -174,6 +174,13 @@ export default {
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}`,
deleteDatasetSkillTree: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/skills`,
deleteDatasetSkillPage: (datasetId: string, skillKwd: string) =>
`${restAPIv1}/datasets/${datasetId}/skills/${skillKwd
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')}`,
// data pipeline log
fetchDataPipelineLog: (datasetId: string) =>
`${restAPIv1}/datasets/${datasetId}/ingestions`,