fix: graph rag ui improvements

This commit is contained in:
Alex committed 2026-06-23 17:11:49 +01:00
1 parent 92e64cc192
commit ba5c9a8910
18 files changed
+787 -174

No files matched your search

+8 -4
View File
@@ -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,
+1
View File
@@ -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"] = {
+2
View File
@@ -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",
+2
View File
@@ -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",
+188 -104
View File
@@ -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));
});
});
+25
View File
@@ -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);
}
+16 -4
View File
@@ -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.",
+8
View File
@@ -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 = {
+12 -1
View File
@@ -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">
+35 -25
View File
@@ -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;
+44 -1
View File
@@ -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>
+20
View File
@@ -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: