mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
fix: graph rag ui improvements
This commit is contained in:
1 parent
92e64cc192
commit
ba5c9a8910
18 files changed
+787
-174
No files matched your search
@@ -1104,14 +1104,18 @@ class EnableSourceGraphRAG(Resource):
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
if not graphrag_available():
|
||||
current_app.logger.warning(
|
||||
"GraphRAG enable rejected for %s: unavailable "
|
||||
"(VECTOR_STORE=%s, GRAPHRAG_ENABLED=%s)",
|
||||
source_id,
|
||||
settings.VECTOR_STORE,
|
||||
settings.GRAPHRAG_ENABLED,
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": (
|
||||
"GraphRAG requires VECTOR_STORE=pgvector and "
|
||||
"GRAPHRAG_ENABLED."
|
||||
),
|
||||
"message": "GraphRAG isn't available on this workspace.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
|
||||
@@ -132,6 +132,7 @@ def get_config():
|
||||
"auth_type": settings.AUTH_TYPE,
|
||||
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
|
||||
"graphrag_available": graphrag_available(),
|
||||
"hybrid_available": settings.VECTOR_STORE == "pgvector",
|
||||
}
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
response["oidc"] = {
|
||||
|
||||
Generated
+2
@@ -21,6 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.2.1",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
@@ -49,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.2.1",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
@@ -62,6 +63,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
|
||||
import { Network, X } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
@@ -14,9 +21,11 @@ import {
|
||||
GraphNode,
|
||||
GraphNodeDetail,
|
||||
GraphOverview,
|
||||
collideRadius,
|
||||
maxDegree,
|
||||
nodeLabelEl,
|
||||
nodeRadius,
|
||||
pointerAreaRadius,
|
||||
toForceGraphData,
|
||||
} from './graphViewUtils';
|
||||
|
||||
@@ -44,7 +53,17 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
const [loadingNode, setLoadingNode] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoveredNodeIdRef = useRef<string | null>(null);
|
||||
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
|
||||
const [size, setSize] = useState({ width: 0, height: 480 });
|
||||
// Bumped after zoom/pan settles to re-supply nodePointerAreaPaint, which makes
|
||||
// force-graph repaint the hit-test (pick) canvas immediately. The engine
|
||||
// otherwise throttles that repaint by 800ms, leaving the pick discs at their
|
||||
// pre-zoom positions — so just after a zoom the cursor reads stale/empty
|
||||
// pixels and hover/click silently fail. The miss scales with the screen
|
||||
// distance a node moved (largest for the biggest, highest-degree hubs) and is
|
||||
// doubled on Retina, where each pick reads a single device pixel.
|
||||
const [pickRefresh, setPickRefresh] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -82,6 +101,17 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
|
||||
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fgRef.current || data.nodes.length === 0) return;
|
||||
fgRef.current.d3Force(
|
||||
'collide',
|
||||
forceCollide<SimulationNodeDatum>((node) =>
|
||||
collideRadius(nodeRadius((node as GraphNode).degree, maxNodeDegree)),
|
||||
),
|
||||
);
|
||||
fgRef.current.d3ReheatSimulation();
|
||||
}, [data, maxNodeDegree, size.width]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setLoadingNode(true);
|
||||
setSelectedNode(null);
|
||||
@@ -95,6 +125,24 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
.finally(() => setLoadingNode(false));
|
||||
};
|
||||
|
||||
// Paints the hit-test (pick) disc for a node. Its identity is keyed on
|
||||
// `pickRefresh` so re-supplying it after a zoom/pan settles forces force-graph
|
||||
// to repaint the pick canvas immediately rather than after its 800ms throttle.
|
||||
const nodePointerAreaPaint = useCallback(
|
||||
(node: object, color: string, ctx: CanvasRenderingContext2D) => {
|
||||
const graphNode = node as GraphNode & { x?: number; y?: number };
|
||||
if (graphNode.x == null || graphNode.y == null) return;
|
||||
const r = pointerAreaRadius(nodeRadius(graphNode.degree, maxNodeDegree));
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(graphNode.x, graphNode.y, r, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
},
|
||||
// pickRefresh is an intentional dependency: bumping it re-supplies this
|
||||
// callback, which makes force-graph flush the pick canvas after a zoom/pan.
|
||||
[maxNodeDegree, pickRefresh],
|
||||
);
|
||||
|
||||
const isEmpty = !loading && data.nodes.length === 0;
|
||||
|
||||
return (
|
||||
@@ -138,111 +186,147 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
<p>{t('settings.sources.graphrag.view.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
|
||||
>
|
||||
{size.width > 0 && (
|
||||
<ForceGraph2D
|
||||
graphData={data}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
nodeRelSize={1}
|
||||
nodeVal={(node) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree)
|
||||
}
|
||||
nodeLabel={(node) =>
|
||||
nodeLabelEl(
|
||||
(node as GraphNode).name,
|
||||
) as unknown as React.ReactHTMLElement<HTMLElement>
|
||||
}
|
||||
linkColor={() => 'rgba(150,150,150,0.35)'}
|
||||
cooldownTicks={80}
|
||||
onNodeClick={(node) => handleNodeClick(node as GraphNode)}
|
||||
nodeCanvasObjectMode={() => 'after'}
|
||||
nodeCanvasObject={(node, ctx, globalScale) => {
|
||||
const graphNode = node as GraphNode & {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
if (globalScale < 1.2 || graphNode.x == null) return;
|
||||
const label = graphNode.name ?? '';
|
||||
ctx.font = `${10 / globalScale}px sans-serif`;
|
||||
ctx.fillStyle = 'rgba(120,120,120,0.95)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(
|
||||
label,
|
||||
graphNode.x,
|
||||
(graphNode.y ?? 0) +
|
||||
nodeRadius(graphNode.degree, maxNodeDegree) /
|
||||
globalScale +
|
||||
1,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.stats', {
|
||||
nodes: data.nodes.length,
|
||||
edges: data.links.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
|
||||
>
|
||||
{size.width > 0 && (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={data}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
nodeRelSize={1}
|
||||
nodeVal={(node) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree)
|
||||
}
|
||||
nodeLabel={(node) =>
|
||||
nodeLabelEl(
|
||||
(node as GraphNode).name,
|
||||
) as unknown as React.ReactHTMLElement<HTMLElement>
|
||||
}
|
||||
linkColor={() => 'rgba(150,150,150,0.35)'}
|
||||
linkPointerAreaPaint={() => {}}
|
||||
cooldownTicks={80}
|
||||
onEngineStop={() => {
|
||||
const fg = fgRef.current;
|
||||
if (!fg) return;
|
||||
fg.zoom(fg.zoom());
|
||||
setPickRefresh((n) => n + 1);
|
||||
}}
|
||||
onZoomEnd={() => setPickRefresh((n) => n + 1)}
|
||||
onNodeClick={(node) => handleNodeClick(node as GraphNode)}
|
||||
onNodeHover={(node) => {
|
||||
hoveredNodeIdRef.current = node
|
||||
? (node as GraphNode).id
|
||||
: null;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = node
|
||||
? 'pointer'
|
||||
: 'default';
|
||||
}
|
||||
}}
|
||||
nodePointerAreaPaint={nodePointerAreaPaint}
|
||||
nodeCanvasObject={(node, ctx, globalScale) => {
|
||||
const graphNode = node as GraphNode & {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
if (graphNode.x == null) return;
|
||||
const r = nodeRadius(graphNode.degree, maxNodeDegree);
|
||||
const hovered = hoveredNodeIdRef.current === graphNode.id;
|
||||
ctx.beginPath();
|
||||
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = hovered ? '#7aa7d9' : '#4a7fb5';
|
||||
ctx.fill();
|
||||
if (globalScale >= 1.2) {
|
||||
const label = graphNode.name ?? '';
|
||||
const x = graphNode.x;
|
||||
const y = (graphNode.y ?? 0) + r + 1;
|
||||
ctx.font = `${10 / globalScale}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = 3 / globalScale;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(10,10,10,0.85)';
|
||||
ctx.strokeText(label, x, y);
|
||||
ctx.fillStyle = hovered
|
||||
? 'rgba(255,255,255,0.98)'
|
||||
: 'rgba(210,210,210,0.95)';
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-border bg-card flex w-full shrink-0 flex-col rounded-xl border p-4 lg:w-80">
|
||||
{loadingNode ? (
|
||||
<SkeletonLoader count={3} />
|
||||
) : selectedNode ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-foreground text-sm font-semibold wrap-break-word">
|
||||
{selectedNode.name}
|
||||
</h3>
|
||||
{selectedNode.type && (
|
||||
<span className="bg-muted-foreground/10 text-muted-foreground mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{selectedNode.type}
|
||||
</span>
|
||||
)}
|
||||
<aside className="border-border bg-card flex w-full shrink-0 flex-col rounded-xl border p-4 lg:w-80">
|
||||
{loadingNode ? (
|
||||
<SkeletonLoader count={3} />
|
||||
) : selectedNode ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-foreground text-sm font-semibold wrap-break-word">
|
||||
{selectedNode.name}
|
||||
</h3>
|
||||
{selectedNode.type && (
|
||||
<span className="bg-muted-foreground/10 text-muted-foreground mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{selectedNode.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
aria-label={t('settings.sources.graphrag.view.close')}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
aria-label={t('settings.sources.graphrag.view.close')}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{selectedNode.description && (
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
|
||||
{selectedNode.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h4 className="text-foreground mb-2 text-xs font-semibold">
|
||||
{t('settings.sources.graphrag.view.linkedChunks')}
|
||||
</h4>
|
||||
{selectedNode.chunks.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.noChunks')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{selectedNode.chunks.map((chunk) => (
|
||||
<li
|
||||
key={chunk.chunk_id}
|
||||
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
|
||||
>
|
||||
{chunk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode.description && (
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
|
||||
{selectedNode.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h4 className="text-foreground mb-2 text-xs font-semibold">
|
||||
{t('settings.sources.graphrag.view.linkedChunks')}
|
||||
</h4>
|
||||
{selectedNode.chunks.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.noChunks')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{selectedNode.chunks.map((chunk) => (
|
||||
<li
|
||||
key={chunk.chunk_id}
|
||||
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
|
||||
>
|
||||
{chunk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{t('settings.sources.graphrag.view.selectNode')}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{t('settings.sources.graphrag.view.selectNode')}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GraphOverview,
|
||||
collideRadius,
|
||||
maxDegree,
|
||||
nodeLabelEl,
|
||||
nodeRadius,
|
||||
pointerAreaRadius,
|
||||
toForceGraphData,
|
||||
} from './graphViewUtils';
|
||||
|
||||
@@ -80,3 +82,33 @@ describe('nodeRadius', () => {
|
||||
expect(nodeRadius(10, 10)).toBeCloseTo(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointerAreaRadius', () => {
|
||||
it('enforces a forgiving minimum hit target for small nodes', () => {
|
||||
const smallest = nodeRadius(0, 0);
|
||||
expect(pointerAreaRadius(smallest)).toBe(9);
|
||||
});
|
||||
|
||||
it('always pads beyond the visual radius for larger nodes', () => {
|
||||
const largest = nodeRadius(10, 10);
|
||||
expect(pointerAreaRadius(largest)).toBeGreaterThan(largest);
|
||||
expect(pointerAreaRadius(largest)).toBeCloseTo(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collideRadius', () => {
|
||||
it('keeps centres farther apart than any node could be picked', () => {
|
||||
// The picking guarantee: with forceCollide(collideRadius), two centres
|
||||
// are >= 2*collideRadius apart, which must exceed the largest pick disc
|
||||
// so a centre is never covered by a neighbour's pick disc.
|
||||
const smallVisual = nodeRadius(0, 0);
|
||||
const largeVisual = nodeRadius(10, 10);
|
||||
const minCentreSpacing = 2 * collideRadius(smallVisual);
|
||||
expect(minCentreSpacing).toBeGreaterThan(pointerAreaRadius(largeVisual));
|
||||
});
|
||||
|
||||
it('always exceeds the pick disc for the same node', () => {
|
||||
const visual = nodeRadius(3, 57);
|
||||
expect(collideRadius(visual)).toBeGreaterThan(pointerAreaRadius(visual));
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ export function toForceGraphData(overview: GraphOverview): ForceGraphData {
|
||||
|
||||
const MIN_NODE_RADIUS = 3;
|
||||
const MAX_NODE_RADIUS = 12;
|
||||
const MIN_POINTER_RADIUS = 7;
|
||||
const POINTER_RADIUS_PAD = 2;
|
||||
const COLLIDE_PAD = 2;
|
||||
|
||||
export function nodeRadius(degree: number, maxDegree: number): number {
|
||||
if (maxDegree <= 0) return MIN_NODE_RADIUS;
|
||||
@@ -51,6 +54,28 @@ export function nodeRadius(degree: number, maxDegree: number): number {
|
||||
return MIN_NODE_RADIUS + scale * (MAX_NODE_RADIUS - MIN_NODE_RADIUS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit-test disc radius for a node in the color-picking canvas.
|
||||
*
|
||||
* Kept deliberately close to the visual radius: the engine resolves
|
||||
* hover/click by reading a single pixel at the node centre, so a pick disc
|
||||
* larger than the inter-node spacing lets a neighbour's disc cover this
|
||||
* node's centre and steal its picks. Pair with {@link collideRadius}, which
|
||||
* spaces centres farther apart than the largest pick disc so a centre is
|
||||
* never covered by another node.
|
||||
*/
|
||||
export function pointerAreaRadius(visualRadius: number): number {
|
||||
return Math.max(visualRadius, MIN_POINTER_RADIUS) + POINTER_RADIUS_PAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collision radius keeping node centres farther apart than any pick disc,
|
||||
* so every centre pixel stays clean for the engine's exact-colour lookup.
|
||||
*/
|
||||
export function collideRadius(visualRadius: number): number {
|
||||
return pointerAreaRadius(visualRadius) + COLLIDE_PAD;
|
||||
}
|
||||
|
||||
export function maxDegree(nodes: GraphNode[]): number {
|
||||
return nodes.reduce((acc, node) => Math.max(acc, node.degree || 0), 0);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"introGeneric": "Build a knowledge graph over this source so agents can reason across linked entities and relationships.",
|
||||
"costExtraction": "Entities and relationships are extracted by running an LLM over this source's chunks.",
|
||||
"costCost": "This makes one LLM call per chunk, so it consumes tokens and may take a while on large sources.",
|
||||
"costPgvector": "GraphRAG is available only with the pgvector store.",
|
||||
"estimate": "~1 LLM call per chunk · ~{{lo}}–{{hi}} total tokens (about half input, half output); output varies with how entity-dense the document is.",
|
||||
"confirm": "Enable GraphRAG",
|
||||
"inProgress": "Building graph…",
|
||||
"summaryNodes_one": "{{count}} entity",
|
||||
@@ -179,6 +179,7 @@
|
||||
"action": "View graph",
|
||||
"title": "Knowledge graph",
|
||||
"explainer": "Nodes are entities and edges are their relationships. Click a node to see its description and the chunks it was extracted from.",
|
||||
"stats": "{{nodes}} entities · {{edges}} relationships",
|
||||
"empty": "No graph yet — extraction may still be running.",
|
||||
"selectNode": "Select a node to see its details.",
|
||||
"linkedChunks": "Linked chunks",
|
||||
@@ -224,14 +225,15 @@
|
||||
"chunks": "Chunks to retrieve (top-k)",
|
||||
"scoreThreshold": "Score threshold",
|
||||
"scoreThresholdPlaceholder": "None",
|
||||
"scoreThresholdHint": "Best-effort: only pgvector and MongoDB honor this; other vector stores ignore it.",
|
||||
"scoreThresholdHint": "Only return chunks above this relevance score (0–1); higher is stricter.",
|
||||
"rephraseQuery": "Rephrase query before search",
|
||||
"retriever": "Retriever",
|
||||
"retrievers": {
|
||||
"classic": "Classic",
|
||||
"hybrid": "Hybrid"
|
||||
"hybrid": "Hybrid",
|
||||
"graphrag": "GraphRAG"
|
||||
},
|
||||
"retrieverHint": "Hybrid (keyword + vector) search is supported on pgvector; other vector stores fall back to vector-only.",
|
||||
"retrieverHint": "Hybrid combines keyword + vector search. GraphRAG builds a knowledge graph over this source for multi-hop questions.",
|
||||
"exposure": "Search exposure",
|
||||
"exposures": {
|
||||
"prefetch": "Pre-fetch into context",
|
||||
@@ -248,6 +250,16 @@
|
||||
"model": "Model (optional)",
|
||||
"modelPlaceholder": "Defaults to the request model"
|
||||
},
|
||||
"graph": {
|
||||
"title": "Graph extraction",
|
||||
"tag": "re-ingest to apply",
|
||||
"extractionModel": "Extraction model",
|
||||
"extractionModelHint": "LLM used to extract entities and relationships from each chunk.",
|
||||
"defaultModel": "Use default model",
|
||||
"maxChunks": "Max chunks to extract",
|
||||
"maxChunksHint": "Cap how many chunks are sent for extraction. Leave empty to use the instance default.",
|
||||
"maxChunksPlaceholder": "Default"
|
||||
},
|
||||
"chunking": {
|
||||
"title": "Chunking",
|
||||
"note": "Ingest-time settings. Changing these requires re-ingesting the source.",
|
||||
|
||||
@@ -42,10 +42,18 @@ export type SourceRetrievalConfig = {
|
||||
prescreen?: SourcePrescreenConfig | null; // null = off
|
||||
};
|
||||
|
||||
// Ingest-time GraphRAG extraction knobs (only used when kind === 'graphrag').
|
||||
export type SourceGraphConfig = {
|
||||
extraction_model?: string | null; // null → instance default model
|
||||
max_chunks?: number | null; // null → GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION
|
||||
gleanings?: number; // extra extraction passes per chunk, default 0
|
||||
};
|
||||
|
||||
export type SourceConfig = {
|
||||
kind?: string; // default 'classic'
|
||||
chunking?: SourceChunkingConfig;
|
||||
retrieval?: SourceRetrievalConfig;
|
||||
graph?: SourceGraphConfig;
|
||||
};
|
||||
|
||||
export type Doc = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { selectToken } from '../preferences/preferenceSlice';
|
||||
|
||||
import {
|
||||
GraphRAGSummary,
|
||||
estimateGraphTokens,
|
||||
pollTaskOnce,
|
||||
startGraphRAG,
|
||||
} from './graphragEnableUtils';
|
||||
@@ -35,6 +36,11 @@ export default function EnableGraphRAGModal({
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const estimate = estimateGraphTokens(
|
||||
Number(document?.tokens) || 0,
|
||||
document?.config?.chunking?.max_tokens,
|
||||
);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('confirm');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<GraphRAGSummary | null>(null);
|
||||
@@ -161,8 +167,13 @@ export default function EnableGraphRAGModal({
|
||||
<ul className="text-muted-foreground list-disc space-y-1.5 pl-5 text-sm">
|
||||
<li>{t('settings.sources.graphrag.enable.costExtraction')}</li>
|
||||
<li>{t('settings.sources.graphrag.enable.costCost')}</li>
|
||||
<li>{t('settings.sources.graphrag.enable.costPgvector')}</li>
|
||||
</ul>
|
||||
<div className="bg-muted text-muted-foreground rounded-xl p-3 text-sm">
|
||||
{t('settings.sources.graphrag.enable.estimate', {
|
||||
lo: estimate.lo.toLocaleString(),
|
||||
hi: estimate.hi.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ import Spinner from '../components/Spinner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
|
||||
import RetrievalOptions, {
|
||||
@@ -24,6 +25,13 @@ interface SourceConfigModalProps {
|
||||
// Fired after a save that flips requires_reingest, when the user confirms
|
||||
// the re-ingest. Reuses the existing Sources.tsx reingest action.
|
||||
onReingest: (document: Doc) => void;
|
||||
hybridAvailable?: boolean;
|
||||
graphRAGAvailable?: boolean;
|
||||
availableModels?: Model[];
|
||||
// Switching the retriever to graphrag on a non-graphrag source can't go
|
||||
// through the config PATCH (the backend blocks kind→graphrag); the parent
|
||||
// routes it through EnableGraphRAGModal instead.
|
||||
onEnableGraphRAG: (document: Doc) => void;
|
||||
}
|
||||
|
||||
export default function SourceConfigModal({
|
||||
@@ -31,6 +39,10 @@ export default function SourceConfigModal({
|
||||
setModalState,
|
||||
document,
|
||||
onReingest,
|
||||
hybridAvailable = false,
|
||||
graphRAGAvailable = false,
|
||||
availableModels = [],
|
||||
onEnableGraphRAG,
|
||||
}: SourceConfigModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -66,8 +78,19 @@ export default function SourceConfigModal({
|
||||
setModalState('INACTIVE');
|
||||
};
|
||||
|
||||
// A non-graphrag source flipped to the graphrag retriever can't be saved via
|
||||
// PATCH (backend blocks kind→graphrag); it enables graph extraction instead.
|
||||
const becomesGraphRAG =
|
||||
options.retrieval.retriever === 'graphrag' &&
|
||||
initial.retrieval.retriever !== 'graphrag';
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!document?.id || isReadOnly) return;
|
||||
if (becomesGraphRAG) {
|
||||
closeModal();
|
||||
onEnableGraphRAG(document);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -163,6 +186,9 @@ export default function SourceConfigModal({
|
||||
onChange={setOptions}
|
||||
alwaysOpen
|
||||
disabled={isReadOnly}
|
||||
hybridAvailable={hybridAvailable}
|
||||
graphRAGAvailable={graphRAGAvailable}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
{willRequireReingest && !isReadOnly && (
|
||||
<div className="rounded-xl bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import modelService from '../api/services/modelService';
|
||||
|
||||
import EyeView from '../assets/eye-view.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
@@ -33,6 +34,7 @@ import { Input } from '../components/ui/input';
|
||||
import { useDarkTheme, useDebouncedValue, useLoaderState } from '../hooks';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
|
||||
import {
|
||||
@@ -129,6 +131,8 @@ export default function Sources({
|
||||
const [graphRAGModalState, setGraphRAGModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [graphRAGAvailable, setGraphRAGAvailable] = useState<boolean>(false);
|
||||
const [hybridAvailable, setHybridAvailable] = useState<boolean>(false);
|
||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||
const [buildingGraphIds, setBuildingGraphIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
@@ -445,28 +449,6 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
document.id &&
|
||||
!isWiki &&
|
||||
!isGraphRAG &&
|
||||
canEdit &&
|
||||
graphRAGAvailable &&
|
||||
document.ingestStatus !== 'processing' &&
|
||||
document.ingestStatus !== 'failed'
|
||||
) {
|
||||
actions.push({
|
||||
icon: Network,
|
||||
label: t('settings.sources.graphrag.enable.action'),
|
||||
onClick: () => {
|
||||
setDocumentToGraphRAG(document);
|
||||
setGraphRAGModalState('ACTIVE');
|
||||
},
|
||||
iconWidth: 16,
|
||||
iconHeight: 16,
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
// Sharing is an owner-only action: hide it for sources shared into the
|
||||
// user's workspace by a team.
|
||||
if (document.ownership !== 'team' && document.id) {
|
||||
@@ -505,7 +487,10 @@ export default function Sources({
|
||||
.getConfig()
|
||||
.then((response) => response.json())
|
||||
.then((config) => {
|
||||
if (!cancelled) setGraphRAGAvailable(!!config?.graphrag_available);
|
||||
if (!cancelled) {
|
||||
setGraphRAGAvailable(!!config?.graphrag_available);
|
||||
setHybridAvailable(!!config?.hybrid_available);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
@@ -513,6 +498,24 @@ export default function Sources({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Models back the graphrag extraction-model picker in the source settings
|
||||
// modal; only fetched when the instance supports graphrag.
|
||||
useEffect(() => {
|
||||
if (!graphRAGAvailable) return;
|
||||
let cancelled = false;
|
||||
modelService
|
||||
.getModels(token)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data)
|
||||
setAvailableModels(modelService.transformModels(data.models || []));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [graphRAGAvailable, token]);
|
||||
|
||||
return documentToView ? (
|
||||
<div className="mt-8 flex flex-col">
|
||||
{documentToView.config?.kind === 'wiki' ||
|
||||
@@ -629,7 +632,7 @@ export default function Sources({
|
||||
setDocumentToView(document);
|
||||
}
|
||||
}}
|
||||
className={`bg-muted dark:bg-accent focus-visible:ring-ring/50 flex h-[130px] w-full cursor-pointer flex-col rounded-2xl p-5 transition-all duration-200 outline-none focus-visible:ring-[3px] ${
|
||||
className={`bg-muted dark:bg-accent focus-visible:ring-ring/50 flex min-h-[130px] w-full cursor-pointer flex-col rounded-2xl p-5 transition-all duration-200 outline-none focus-visible:ring-[3px] ${
|
||||
actionMenuDocId === docId ||
|
||||
syncMenuState.docId === docId
|
||||
? 'scale-[1.05]'
|
||||
@@ -639,7 +642,7 @@ export default function Sources({
|
||||
<div className="w-full flex-1">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<h3
|
||||
className="dark:text-foreground text-foreground line-clamp-3 min-w-0 flex-1 text-sm leading-[18px] font-semibold wrap-anywhere"
|
||||
className="dark:text-foreground text-foreground line-clamp-2 min-w-0 flex-1 text-sm leading-[18px] font-semibold wrap-anywhere"
|
||||
title={document.name}
|
||||
>
|
||||
{document.name}
|
||||
@@ -877,6 +880,13 @@ export default function Sources({
|
||||
}}
|
||||
document={documentToConfigure}
|
||||
onReingest={handleReingest}
|
||||
hybridAvailable={hybridAvailable}
|
||||
graphRAGAvailable={graphRAGAvailable}
|
||||
availableModels={availableModels}
|
||||
onEnableGraphRAG={(doc) => {
|
||||
setDocumentToGraphRAG(doc);
|
||||
setGraphRAGModalState('ACTIVE');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConvertToWikiModal
|
||||
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SourceConfig } from '../../models/misc';
|
||||
import {
|
||||
availableRetrievers,
|
||||
configToOptions,
|
||||
optionsToConfig,
|
||||
isPrescreenConfigValid,
|
||||
chunkingChanged,
|
||||
scoreThresholdHidden,
|
||||
DEFAULT_RETRIEVAL_OPTIONS,
|
||||
RetrievalOptionsValue,
|
||||
} from './RetrievalOptions';
|
||||
@@ -49,11 +51,45 @@ describe('configToOptions (lenient read)', () => {
|
||||
});
|
||||
|
||||
describe('optionsToConfig (write path)', () => {
|
||||
it('always emits kind=classic and a full shape', () => {
|
||||
it('emits the carried kind and a full shape', () => {
|
||||
const cfg = optionsToConfig(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
expect(cfg.kind).toBe('classic');
|
||||
expect(cfg.chunking).toBeDefined();
|
||||
expect(cfg.retrieval).toBeDefined();
|
||||
expect(cfg.graph).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves a non-classic kind (never downgrades a wiki source)', () => {
|
||||
const v = clone(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
v.kind = 'wiki';
|
||||
expect(optionsToConfig(v).kind).toBe('wiki');
|
||||
});
|
||||
|
||||
it('forces kind=graphrag when the graphrag retriever is chosen', () => {
|
||||
const v = clone(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
v.kind = 'classic';
|
||||
v.retrieval.retriever = 'graphrag';
|
||||
expect(optionsToConfig(v).kind).toBe('graphrag');
|
||||
});
|
||||
|
||||
it('round-trips the graph config including model and max_chunks', () => {
|
||||
const v = clone(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
v.kind = 'graphrag';
|
||||
v.retrieval.retriever = 'graphrag';
|
||||
v.graph = { extraction_model: 'gpt-x', max_chunks: 50, gleanings: 1 };
|
||||
const cfg = optionsToConfig(v);
|
||||
expect(cfg.graph).toEqual({
|
||||
extraction_model: 'gpt-x',
|
||||
max_chunks: 50,
|
||||
gleanings: 1,
|
||||
});
|
||||
expect(configToOptions(cfg)).toEqual(v);
|
||||
});
|
||||
|
||||
it('normalizes a whitespace extraction model to null', () => {
|
||||
const v = clone(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
v.graph.extraction_model = ' ';
|
||||
expect(optionsToConfig(v).graph?.extraction_model).toBe(null);
|
||||
});
|
||||
|
||||
it('nests prescreen to null when disabled', () => {
|
||||
@@ -90,6 +126,7 @@ describe('round-trip configToOptions(optionsToConfig(x)) == x', () => {
|
||||
|
||||
it('round-trips a fully-customized canonical value', () => {
|
||||
const v: RetrievalOptionsValue = {
|
||||
kind: 'classic',
|
||||
chunking: {
|
||||
strategy: 'parent_child',
|
||||
max_tokens: 800,
|
||||
@@ -110,6 +147,11 @@ describe('round-trip configToOptions(optionsToConfig(x)) == x', () => {
|
||||
max_keep: 10,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: null,
|
||||
max_chunks: null,
|
||||
gleanings: 0,
|
||||
},
|
||||
};
|
||||
expect(configToOptions(optionsToConfig(v))).toEqual(v);
|
||||
});
|
||||
@@ -164,3 +206,48 @@ describe('chunkingChanged', () => {
|
||||
expect(chunkingChanged(DEFAULT_RETRIEVAL_OPTIONS, after)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availableRetrievers', () => {
|
||||
it('always offers classic', () => {
|
||||
expect(availableRetrievers('classic', false, false)).toEqual(['classic']);
|
||||
});
|
||||
|
||||
it('hides hybrid when unavailable and not already selected', () => {
|
||||
expect(availableRetrievers('classic', false, false)).not.toContain(
|
||||
'hybrid',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows hybrid when available', () => {
|
||||
expect(availableRetrievers('classic', true, false)).toContain('hybrid');
|
||||
});
|
||||
|
||||
it('shows hybrid when already selected even if unavailable', () => {
|
||||
expect(availableRetrievers('hybrid', false, false)).toContain('hybrid');
|
||||
});
|
||||
|
||||
it('hides graphrag when unavailable and not already selected', () => {
|
||||
expect(availableRetrievers('classic', false, false)).not.toContain(
|
||||
'graphrag',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows graphrag when available', () => {
|
||||
expect(availableRetrievers('classic', false, true)).toContain('graphrag');
|
||||
});
|
||||
|
||||
it('shows graphrag when already selected even if unavailable', () => {
|
||||
expect(availableRetrievers('graphrag', false, false)).toContain('graphrag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreThresholdHidden', () => {
|
||||
it('shows the threshold for the classic retriever', () => {
|
||||
expect(scoreThresholdHidden('classic')).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the threshold for hybrid and graphrag', () => {
|
||||
expect(scoreThresholdHidden('hybrid')).toBe(true);
|
||||
expect(scoreThresholdHidden('graphrag')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
RetrievalExposure,
|
||||
SourceConfig,
|
||||
} from '../../models/misc';
|
||||
import type { Model } from '../../models/types';
|
||||
|
||||
import ChevronRight from '../../assets/chevron-right.svg';
|
||||
|
||||
@@ -35,7 +36,10 @@ export const DEFAULT_PRESCREEN = {
|
||||
|
||||
// Fully-populated form shape so every control is controlled. Prescreen is
|
||||
// flattened into the form (with an `enabled` flag) and re-nested on serialize.
|
||||
// `kind` is carried so serialization preserves a source's behavior selector
|
||||
// (classic/wiki/graphrag) instead of silently downgrading it.
|
||||
export type RetrievalOptionsValue = {
|
||||
kind: string;
|
||||
chunking: {
|
||||
strategy: ChunkingStrategy;
|
||||
max_tokens: number;
|
||||
@@ -56,9 +60,15 @@ export type RetrievalOptionsValue = {
|
||||
max_keep: number;
|
||||
};
|
||||
};
|
||||
graph: {
|
||||
extraction_model: string | null;
|
||||
max_chunks: number | null;
|
||||
gleanings: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_RETRIEVAL_OPTIONS: RetrievalOptionsValue = {
|
||||
kind: 'classic',
|
||||
chunking: {
|
||||
strategy: 'classic_chunk',
|
||||
max_tokens: 1250,
|
||||
@@ -76,6 +86,11 @@ export const DEFAULT_RETRIEVAL_OPTIONS: RetrievalOptionsValue = {
|
||||
...DEFAULT_PRESCREEN,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: null,
|
||||
max_chunks: null,
|
||||
gleanings: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const CHUNKING_STRATEGIES: ChunkingStrategy[] = [
|
||||
@@ -86,7 +101,41 @@ const CHUNKING_STRATEGIES: ChunkingStrategy[] = [
|
||||
'semantic',
|
||||
];
|
||||
|
||||
const RETRIEVERS = ['classic', 'hybrid'];
|
||||
const CLASSIC_RETRIEVER = 'classic';
|
||||
const HYBRID_RETRIEVER = 'hybrid';
|
||||
const GRAPHRAG_RETRIEVER = 'graphrag';
|
||||
// Sentinel select value for "use the instance default model" (Select can't take
|
||||
// an empty/null item value); maps to extraction_model = null on change.
|
||||
const GRAPH_DEFAULT_MODEL = '__default__';
|
||||
|
||||
/**
|
||||
* Score threshold is a cosine-similarity cutoff; the hybrid (RRF) and graphrag
|
||||
* (PPR) retrievers don't produce comparable scores, so the control is hidden
|
||||
* for them.
|
||||
*/
|
||||
export function scoreThresholdHidden(retriever: string): boolean {
|
||||
return retriever === HYBRID_RETRIEVER || retriever === GRAPHRAG_RETRIEVER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retriever options to offer. Classic is always available; hybrid and graphrag
|
||||
* are gated on instance support, but the currently-selected retriever is always
|
||||
* included so an existing source never renders an out-of-range select.
|
||||
*/
|
||||
export function availableRetrievers(
|
||||
current: string,
|
||||
hybridAvailable: boolean,
|
||||
graphRAGAvailable: boolean,
|
||||
): string[] {
|
||||
const options = [CLASSIC_RETRIEVER];
|
||||
if (hybridAvailable || current === HYBRID_RETRIEVER) {
|
||||
options.push(HYBRID_RETRIEVER);
|
||||
}
|
||||
if (graphRAGAvailable || current === GRAPHRAG_RETRIEVER) {
|
||||
options.push(GRAPHRAG_RETRIEVER);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group eyebrow: an uppercase, tracked title with a normal-weight muted ` · tag`
|
||||
@@ -155,8 +204,10 @@ export function configToOptions(config?: SourceConfig): RetrievalOptionsValue {
|
||||
const chunking = config?.chunking ?? {};
|
||||
const retrieval = config?.retrieval ?? {};
|
||||
const prescreen = retrieval.prescreen ?? null;
|
||||
const graph = config?.graph ?? {};
|
||||
const d = DEFAULT_RETRIEVAL_OPTIONS;
|
||||
return {
|
||||
kind: config?.kind ?? d.kind,
|
||||
chunking: {
|
||||
strategy: chunking.strategy ?? d.chunking.strategy,
|
||||
max_tokens: chunking.max_tokens ?? d.chunking.max_tokens,
|
||||
@@ -178,19 +229,28 @@ export function configToOptions(config?: SourceConfig): RetrievalOptionsValue {
|
||||
max_keep: prescreen?.max_keep ?? DEFAULT_PRESCREEN.max_keep,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: graph.extraction_model ?? d.graph.extraction_model,
|
||||
max_chunks: graph.max_chunks ?? d.graph.max_chunks,
|
||||
gleanings: graph.gleanings ?? d.graph.gleanings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the form value into a full SourceConfig object the backend accepts.
|
||||
* The backend uses `extra="forbid"` and re-validates the whole object, so we
|
||||
* always send the complete (kind + chunking + retrieval) shape. Prescreen is
|
||||
* re-nested to `null` when disabled.
|
||||
* always send the complete (kind + chunking + retrieval + graph) shape.
|
||||
* Prescreen is re-nested to `null` when disabled. `kind` is preserved from the
|
||||
* value and forced to `graphrag` when the graphrag retriever is chosen (so the
|
||||
* create-flow ingest auto-extracts), never silently downgrading wiki/graphrag.
|
||||
*/
|
||||
export function optionsToConfig(value: RetrievalOptionsValue): SourceConfig {
|
||||
const ps = value.retrieval.prescreen;
|
||||
const kind =
|
||||
value.retrieval.retriever === GRAPHRAG_RETRIEVER ? 'graphrag' : value.kind;
|
||||
return {
|
||||
kind: 'classic',
|
||||
kind,
|
||||
chunking: {
|
||||
strategy: value.chunking.strategy,
|
||||
max_tokens: value.chunking.max_tokens,
|
||||
@@ -212,6 +272,13 @@ export function optionsToConfig(value: RetrievalOptionsValue): SourceConfig {
|
||||
}
|
||||
: null,
|
||||
},
|
||||
graph: {
|
||||
extraction_model: value.graph.extraction_model?.trim()
|
||||
? value.graph.extraction_model.trim()
|
||||
: null,
|
||||
max_chunks: value.graph.max_chunks,
|
||||
gleanings: value.graph.gleanings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +315,14 @@ type RetrievalOptionsProps = {
|
||||
// its own collapsible toggle (create-flow use). Defaults to collapsible.
|
||||
alwaysOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
// Adds the hybrid retriever option when the instance supports it (pgvector,
|
||||
// from /api/config).
|
||||
hybridAvailable?: boolean;
|
||||
// Adds the graphrag retriever option + extraction config when the instance
|
||||
// supports it (pgvector + GRAPHRAG_ENABLED, from /api/config).
|
||||
graphRAGAvailable?: boolean;
|
||||
// Models for the graph extraction-model picker, same shape as the agent form.
|
||||
availableModels?: Model[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -260,6 +335,9 @@ export default function RetrievalOptions({
|
||||
onChange,
|
||||
alwaysOpen = false,
|
||||
disabled = false,
|
||||
hybridAvailable = false,
|
||||
graphRAGAvailable = false,
|
||||
availableModels = [],
|
||||
}: RetrievalOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -274,6 +352,17 @@ export default function RetrievalOptions({
|
||||
[t],
|
||||
);
|
||||
|
||||
const isGraphRAG = value.retrieval.retriever === GRAPHRAG_RETRIEVER;
|
||||
const retrievers = useMemo(
|
||||
() =>
|
||||
availableRetrievers(
|
||||
value.retrieval.retriever,
|
||||
hybridAvailable,
|
||||
graphRAGAvailable,
|
||||
),
|
||||
[value.retrieval.retriever, hybridAvailable, graphRAGAvailable],
|
||||
);
|
||||
|
||||
const setRetrieval = (patch: Partial<RetrievalOptionsValue['retrieval']>) => {
|
||||
onChange({
|
||||
...value,
|
||||
@@ -296,6 +385,22 @@ export default function RetrievalOptions({
|
||||
});
|
||||
};
|
||||
|
||||
const setGraph = (patch: Partial<RetrievalOptionsValue['graph']>) => {
|
||||
onChange({
|
||||
...value,
|
||||
graph: { ...value.graph, ...patch },
|
||||
});
|
||||
};
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const builtin: Model[] = [];
|
||||
const user: Model[] = [];
|
||||
availableModels.forEach((m) =>
|
||||
(m.source === 'user' ? user : builtin).push(m),
|
||||
);
|
||||
return { builtin, user };
|
||||
}, [availableModels]);
|
||||
|
||||
const tr = (key: string) => t(`settings.sources.retrievalOptions.${key}`);
|
||||
|
||||
const body = (
|
||||
@@ -324,7 +429,7 @@ export default function RetrievalOptions({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RETRIEVERS.map((r) => (
|
||||
{retrievers.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{tr(`retrieval.retrievers.${r}`)}
|
||||
</SelectItem>
|
||||
@@ -349,35 +454,37 @@ export default function RetrievalOptions({
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={tr('retrieval.scoreThreshold')}
|
||||
htmlFor="retrieval-score-threshold"
|
||||
>
|
||||
<Input
|
||||
id="retrieval-score-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.01"
|
||||
className="w-24 text-right"
|
||||
value={
|
||||
value.retrieval.score_threshold === null
|
||||
? ''
|
||||
: String(value.retrieval.score_threshold)
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={tr('retrieval.scoreThresholdPlaceholder')}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setRetrieval({
|
||||
score_threshold:
|
||||
raw === ''
|
||||
? null
|
||||
: Math.min(1, Math.max(0, Number(raw) || 0)),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{!scoreThresholdHidden(value.retrieval.retriever) && (
|
||||
<SettingRow
|
||||
label={tr('retrieval.scoreThreshold')}
|
||||
htmlFor="retrieval-score-threshold"
|
||||
>
|
||||
<Input
|
||||
id="retrieval-score-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.01"
|
||||
className="w-24 text-right"
|
||||
value={
|
||||
value.retrieval.score_threshold === null
|
||||
? ''
|
||||
: String(value.retrieval.score_threshold)
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={tr('retrieval.scoreThresholdPlaceholder')}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setRetrieval({
|
||||
score_threshold:
|
||||
raw === ''
|
||||
? null
|
||||
: Math.min(1, Math.max(0, Number(raw) || 0)),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label={tr('retrieval.rephraseQuery')}
|
||||
@@ -491,6 +598,83 @@ export default function RetrievalOptions({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph extraction group (graphrag only; re-ingest required to apply) */}
|
||||
{isGraphRAG && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<GroupHeader title={tr('graph.title')} tag={tr('graph.tag')} />
|
||||
|
||||
<div className="divide-border/50 divide-y">
|
||||
<SettingRow
|
||||
label={tr('graph.extractionModel')}
|
||||
htmlFor="graph-extraction-model"
|
||||
description={tr('graph.extractionModelHint')}
|
||||
alignStart
|
||||
>
|
||||
<Select
|
||||
value={value.graph.extraction_model ?? GRAPH_DEFAULT_MODEL}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) =>
|
||||
setGraph({
|
||||
extraction_model: v === GRAPH_DEFAULT_MODEL ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="graph-extraction-model"
|
||||
className="w-52 rounded-md"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={GRAPH_DEFAULT_MODEL}>
|
||||
{tr('graph.defaultModel')}
|
||||
</SelectItem>
|
||||
{modelOptions.builtin.map((m) => (
|
||||
<SelectItem key={`builtin-${m.id}`} value={m.id}>
|
||||
{m.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{modelOptions.user.map((m) => (
|
||||
<SelectItem key={`user-${m.id}`} value={m.id}>
|
||||
{m.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={tr('graph.maxChunks')}
|
||||
htmlFor="graph-max-chunks"
|
||||
description={tr('graph.maxChunksHint')}
|
||||
alignStart
|
||||
>
|
||||
<Input
|
||||
id="graph-max-chunks"
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24 text-right"
|
||||
value={
|
||||
value.graph.max_chunks === null
|
||||
? ''
|
||||
: String(value.graph.max_chunks)
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={tr('graph.maxChunksPlaceholder')}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setGraph({
|
||||
max_chunks:
|
||||
raw === '' ? null : Math.max(1, Number(raw) || 1),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunking group (re-ingest required) */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<GroupHeader title={tr('chunking.title')} tag={tr('chunking.tag')} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
estimateGraphTokens,
|
||||
interpretTaskStatus,
|
||||
pollTaskOnce,
|
||||
startGraphRAG,
|
||||
@@ -13,6 +14,35 @@ const jsonResponse = (status: number, body: unknown): Response =>
|
||||
json: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('estimateGraphTokens', () => {
|
||||
it('computes chunk count from source tokens and chunk max', () => {
|
||||
const e = estimateGraphTokens(10000, 1250);
|
||||
expect(e.chunks).toBe(8);
|
||||
expect(e.lo).toBe(2500 * 8);
|
||||
expect(e.hi).toBe(4000 * 8);
|
||||
});
|
||||
|
||||
it('rounds the chunk count up (ceil)', () => {
|
||||
expect(estimateGraphTokens(1300, 1250).chunks).toBe(2);
|
||||
});
|
||||
|
||||
it('defaults the chunk max to 1250', () => {
|
||||
expect(estimateGraphTokens(2500).chunks).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to at least one chunk for tiny/zero sources', () => {
|
||||
const e = estimateGraphTokens(0, 1250);
|
||||
expect(e.chunks).toBe(1);
|
||||
expect(e.lo).toBe(2500);
|
||||
expect(e.hi).toBe(4000);
|
||||
});
|
||||
|
||||
it('guards against a non-positive chunk max', () => {
|
||||
expect(estimateGraphTokens(2500, 0).chunks).toBe(2);
|
||||
expect(estimateGraphTokens(2500, -10).chunks).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startGraphRAG', () => {
|
||||
it('returns the task id when extraction is enqueued', async () => {
|
||||
const enableGraphRAG = vi
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
export interface GraphTokenEstimate {
|
||||
chunks: number;
|
||||
lo: number;
|
||||
hi: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_MAX_TOKENS = 1250;
|
||||
const TOKENS_PER_CHUNK_LO = 2500;
|
||||
const TOKENS_PER_CHUNK_HI = 4000;
|
||||
|
||||
/**
|
||||
* Estimate the token cost of GraphRAG extraction before it runs. Extraction
|
||||
* makes ~1 LLM call per chunk; measured calls land at ~2.5k–4k tokens each
|
||||
* (roughly half input, half output), so the range scales with the chunk count.
|
||||
*/
|
||||
export function estimateGraphTokens(
|
||||
sourceTokens: number,
|
||||
chunkMaxTokens: number = DEFAULT_CHUNK_MAX_TOKENS,
|
||||
): GraphTokenEstimate {
|
||||
const tokens = Number.isFinite(sourceTokens) ? Math.max(0, sourceTokens) : 0;
|
||||
const perChunk =
|
||||
Number.isFinite(chunkMaxTokens) && chunkMaxTokens > 0
|
||||
? chunkMaxTokens
|
||||
: DEFAULT_CHUNK_MAX_TOKENS;
|
||||
const chunks = Math.max(1, Math.ceil(tokens / perChunk));
|
||||
return {
|
||||
chunks,
|
||||
lo: TOKENS_PER_CHUNK_LO * chunks,
|
||||
hi: TOKENS_PER_CHUNK_HI * chunks,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GraphRAGSummary {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -6,6 +6,8 @@ import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
|
||||
import type { RootState } from '../store';
|
||||
import userService from '../api/services/userService';
|
||||
import modelService from '../api/services/modelService';
|
||||
import type { Model } from '../models/types';
|
||||
import { getSessionToken } from '../utils/providerUtils';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
@@ -74,11 +76,49 @@ function Upload({
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [retrievalOptions, setRetrievalOptions] =
|
||||
useState<RetrievalOptionsValue>(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
const [graphRAGAvailable, setGraphRAGAvailable] = useState(false);
|
||||
const [hybridAvailable, setHybridAvailable] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||
|
||||
// File picker state
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getConfig()
|
||||
.then((response) => response.json())
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setGraphRAGAvailable(!!config?.graphrag_available);
|
||||
setHybridAvailable(!!config?.hybrid_available);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Models back the graphrag extraction-model picker; only fetched when the
|
||||
// instance supports graphrag.
|
||||
useEffect(() => {
|
||||
if (!graphRAGAvailable) return;
|
||||
let cancelled = false;
|
||||
modelService
|
||||
.getModels(token)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data)
|
||||
setAvailableModels(modelService.transformModels(data.models || []));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [graphRAGAvailable, token]);
|
||||
|
||||
const renderFormFields = () => {
|
||||
if (!ingestor.type) return null;
|
||||
const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType);
|
||||
@@ -1039,6 +1079,9 @@ function Upload({
|
||||
<RetrievalOptions
|
||||
value={retrievalOptions}
|
||||
onChange={setRetrievalOptions}
|
||||
hybridAvailable={hybridAvailable}
|
||||
graphRAGAvailable={graphRAGAvailable}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,26 @@ class TestConfigRoute:
|
||||
data = json.loads(response.data)
|
||||
assert data["graphrag_available"] is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_hybrid_available_when_pgvector(self, client):
|
||||
with patch("application.app.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = None
|
||||
mock_settings.VECTOR_STORE = "pgvector"
|
||||
response = client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["hybrid_available"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_hybrid_unavailable_when_not_pgvector(self, client):
|
||||
with patch("application.app.settings") as mock_settings:
|
||||
mock_settings.AUTH_TYPE = None
|
||||
mock_settings.VECTOR_STORE = "faiss"
|
||||
response = client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["hybrid_available"] is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_oidc_config_exposes_login_paths(self, client):
|
||||
with patch("application.app.settings") as mock_settings:
|
||||
|
||||
Reference in new issue
Block a user