Feat: Visualize session graph data using ArtifactForceGraph. (#16977)

### Summary

Feat: Visualize session graph data using ArtifactForceGraph.
This commit is contained in:
balibabu
2026-07-16 20:54:58 +08:00
committed by GitHub
parent c599dc6c52
commit 548f296ead
7 changed files with 104 additions and 63 deletions

View File

@@ -70,27 +70,10 @@ pre-commit:
else
gofmt -w {staged_files}
fi
- name: web-deps
glob: "web/**/*.{css,less,json,js,jsx,ts,tsx}"
run: |
# Ensure web/node_modules is populated. CI runners don't run `npm install`
# before lefthook, and `npx` without local node_modules fetches the
# latest packages from the registry - which breaks because the pinned
# prettier plugins (prettier-plugin-organize-imports,
# prettier-plugin-packagejson) aren't auto-resolved and ESLint 10
# requires eslint.config.js.
# Serialized via lefthook 'deps' from web-prettier/web-eslint instead of
# a mkdir mutex, which left stale locks on interrupted commits and
# caused subsequent commits to hang forever in 'while ! mkdir'.
if [ ! -f web/node_modules/.package-lock.json ]; then
echo '==> web/node_modules missing or incomplete; running npm ci --prefix web'
rm -rf web/node_modules
npm ci --prefix web --no-audit --no-fund
fi
- name: web-prettier
# prettier plugins removed from .prettierrc, so this job needs no node_modules.
glob: "web/**/*.{css,less,json,js,jsx,ts,tsx}"
stage_fixed: true
deps: [web-deps]
run: |
if [ "${LEFTHOOK_CHECK_ONLY:-}" = "1" ]; then
cd web && printf '%s\n' {staged_files} | sed 's|^web/||' | xargs npx prettier --check --ignore-unknown
@@ -98,10 +81,16 @@ pre-commit:
cd web && printf '%s\n' {staged_files} | sed 's|^web/||' | xargs npx prettier --write --ignore-unknown
fi
- name: web-eslint
# Only web-eslint needs node_modules (for @typescript-eslint/* plugins).
# web-prettier has no npm deps, so there is no race on node_modules.
glob: "web/**/*.{js,jsx,ts,tsx}"
stage_fixed: true
deps: [web-deps]
run: |
if [ ! -f web/node_modules/.package-lock.json ]; then
echo '==> web/node_modules missing or incomplete; running npm ci --prefix web'
rm -rf web/node_modules
npm ci --prefix web --no-audit --no-fund
fi
if [ "${LEFTHOOK_CHECK_ONLY:-}" = "1" ]; then
cd web && printf '%s\n' {staged_files} | sed 's|^web/||' | xargs npx eslint
else

View File

@@ -4,9 +4,6 @@
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-packagejson"
],
"plugins": [],
"endOfLine": "lf"
}

View File

@@ -6,6 +6,8 @@ export const enum CompilationTemplateKind {
Tree = 'tree',
Empty = 'empty',
MindMap = 'mind_map',
SessionGraph = 'session_graph',
SessionEssence = 'session_essence',
}
export const enum CompilationTemplateScope {

View File

@@ -233,7 +233,7 @@ export default function FloatingSelectionToolbar() {
alignItems: 'center',
gap: 2,
padding: '4px 6px',
background: 'var(--nim-bg-secondary)',
background: 'var(--bg-base)',
border: '1px solid var(--nim-border)',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',

View File

@@ -1,6 +1,7 @@
import { SkeletonCard } from '@/components/skeleton-card';
import { cn } from '@/lib/utils';
import { Graph, IElementEvent, NodeEvent, treeToGraphData } from '@antv/g6';
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { adaptMindMapToIndentedTree } from '../../utils/adapters';
import { type MindMapG6GraphProps, type MindMapNodeValue } from './types';
@@ -17,10 +18,28 @@ function MindMapG6Graph({
}: MindMapG6GraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const [loading, setLoading] = useState(true);
const [graphData, setGraphData] = useState<ReturnType<
typeof treeToGraphData
> | null>(null);
// Defer heavy data transformation to avoid blocking the render phase.
useEffect(() => {
setLoading(true);
setGraphData(null);
const rafId = requestAnimationFrame(() => {
setGraphData(treeToGraphData(adaptMindMapToIndentedTree(template)));
});
return () => {
cancelAnimationFrame(rafId);
};
}, [template]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (!container || !graphData) return;
const styles = window.getComputedStyle(container);
const textPrimary = styles.getPropertyValue('--text-primary').trim();
@@ -41,7 +60,8 @@ function MindMapG6Graph({
width,
height,
autoFit: 'view',
data: treeToGraphData(adaptMindMapToIndentedTree(template)),
animation: false,
data: graphData,
node: {
style: {
labelText: (datum) =>
@@ -64,8 +84,7 @@ function MindMapG6Graph({
direction: 'H',
preLayout: false,
getHeight: () => 32,
getWidth: (node: { id: string; data?: MindMapNodeData }) =>
Math.max(120, String(node.data?.name ?? node.id).length * 7),
getWidth: () => 120,
getVGap: () => 10,
getHGap: () => 80,
},
@@ -89,36 +108,50 @@ function MindMapG6Graph({
graph.on<IElementEvent>(NodeEvent.CLICK, handleNodeClick);
graphRef.current = graph;
void graph.render();
void graph.render().then(() => {
setLoading(false);
});
};
observer = new ResizeObserver(() => {
if (!graph) {
createGraph();
return;
}
// Defer graph creation so the browser paints the skeleton before the
// heavy G6 layout + render starts.
const rafId = requestAnimationFrame(() => {
observer = new ResizeObserver(() => {
if (!graph) {
createGraph();
return;
}
const { width, height } = container.getBoundingClientRect();
if (width > 0 && height > 0) {
graph.resize(width, height);
}
const { width, height } = container.getBoundingClientRect();
if (width > 0 && height > 0) {
graph.resize(width, height);
}
});
observer.observe(container);
createGraph();
});
observer.observe(container);
createGraph();
return () => {
cancelAnimationFrame(rafId);
observer?.disconnect();
graph?.destroy();
graphRef.current = null;
};
}, [template, onNodeClick]);
}, [graphData, onNodeClick]);
return (
<div
ref={containerRef}
className={cn('flex-1 min-h-0 h-full', !show && 'hidden')}
/>
<div className="relative flex-1 min-h-0 h-full">
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<SkeletonCard className="w-80" />
</div>
)}
<div
ref={containerRef}
className={cn('h-full', !show && 'hidden', loading && 'invisible')}
/>
</div>
);
}

View File

@@ -170,6 +170,38 @@ export function RepresentationRenderer({
/>
</div>
);
case CompilationTemplateKind.SessionGraph:
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
/>
</div>
);
case CompilationTemplateKind.SessionEssence:
return (
<div className="mt-6 flex-1 min-h-0">
<MindMapG6Graph
template={template}
show
onNodeClick={handleMindMapNodeClick}
/>
</div>
);
case CompilationTemplateKind.Empty:
return (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
/>
</div>
);
default:
return <UnsupportedPlaceholder kind={template.kind} />;
}

View File

@@ -2,12 +2,9 @@ import {
SelectWithSearch,
type SelectWithSearchFlagOptionType,
} from '@/components/originui/select-with-search';
import {
type IStructureGraphTemplate,
type StructureTemplateKind,
} from '@/interfaces/database/document-structure';
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[];
@@ -15,20 +12,11 @@ interface RepresentationSelectProps {
onChange?: (value: string) => void;
}
function getKindLabel(
t: (key: string) => string,
kind: StructureTemplateKind,
): string {
return t(`chunk.representationKinds.${kind}`);
}
export function RepresentationSelect({
templates,
value,
onChange,
}: RepresentationSelectProps) {
const { t } = useTranslation();
const options = useMemo<SelectWithSearchFlagOptionType[]>(() => {
return templates.map((template) => ({
value: template.template_id,
@@ -36,13 +24,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">
{getKindLabel(t, template.kind)}
{formatKindLabel(template.kind)}
</span>
</span>
),
keywords: [template.template_name, template.kind],
}));
}, [templates, t]);
}, [templates]);
return (
<SelectWithSearch