+ {{ hiddenCount }} more - keep typing to narrow it down
+
+
+ Nothing matches "{{ query }}"
+
+
+
+
diff --git a/admin/slices/reins/components/knowledge/graph/Provider.vue b/admin/slices/reins/components/knowledge/graph/Provider.vue
index 9dbf5f28..6300fce7 100644
--- a/admin/slices/reins/components/knowledge/graph/Provider.vue
+++ b/admin/slices/reins/components/knowledge/graph/Provider.vue
@@ -3,6 +3,7 @@ import Sigma from 'sigma';
import type { SigmaNodeEventPayload, SigmaEdgeEventPayload } from 'sigma/types';
import Graph from 'graphology';
import forceAtlas2 from 'graphology-layout-forceatlas2';
+import FA2Layout from 'graphology-layout-forceatlas2/worker';
import {
ComboboxAnchor,
ComboboxContent,
@@ -36,8 +37,17 @@ const selectedNode = ref(null);
const selectedEdge = ref(null);
const nodes = ref([]);
+// How long the layout is allowed to keep running after a graph is drawn.
+// It runs in a worker, so this only bounds how long positions keep moving.
+const LAYOUT_RUN_MS = 4000;
+// Above this many nodes the O(n^2) repulsion pass gets a Barnes-Hut
+// approximation; below it the exact pass is cheaper than the quadtree.
+const BARNES_HUT_FROM_NODES = 50;
+
let renderer: Sigma | null = null;
let graph: Graph | null = null;
+let layout: FA2Layout | null = null;
+let layoutTimer: ReturnType | null = null;
const nodeIndex = new Map();
const edgeIndex = new Map();
@@ -105,6 +115,8 @@ function renderGraph(data: GraphDto): void {
ensureRenderer();
if (!graph) return;
+ // The worker mutates this graph; it has to go before the contents do.
+ stopLayout();
graph.clear();
nodeIndex.clear();
edgeIndex.clear();
@@ -114,15 +126,20 @@ function renderGraph(data: GraphDto): void {
const nodeSize = data.nodes.length > 50 ? 6 : data.nodes.length > 20 ? 8 : 10;
- for (const node of data.nodes) {
- graph.addNode(node.id, {
- x: Math.random(),
- y: Math.random(),
+ // Seeded on a circle rather than at random: the layout starts from an
+ // untangled state, so it converges in far fewer passes and the first frame
+ // is already readable instead of a knot.
+ const g = graph;
+ data.nodes.forEach((node, i) => {
+ const angle = (2 * Math.PI * i) / data.nodes.length;
+ g.addNode(node.id, {
+ x: Math.cos(angle),
+ y: Math.sin(angle),
size: nodeSize,
label: node.label,
color: colorForType(node.entityType),
});
- }
+ });
for (const edge of data.edges) {
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
@@ -134,21 +151,53 @@ function renderGraph(data: GraphDto): void {
}
if (graph.order > 0) {
- const settings = forceAtlas2.inferSettings(graph);
- forceAtlas2.assign(graph, {
- iterations: 200,
- settings: {
- ...settings,
- gravity: 1,
- scalingRatio: 10,
- },
- });
normalizeAndFit();
+ startLayout();
}
renderer?.refresh();
}
+/**
+ * ForceAtlas2 in a web worker. The synchronous `forceAtlas2.assign` this
+ * replaces ran 200 passes on the main thread, which froze the whole tab for
+ * ~15s on a dense graph (repulsion is quadratic in nodes, attraction linear in
+ * edges). The worker keeps the UI interactive while positions settle, and the
+ * run is cut off after LAYOUT_RUN_MS so it cannot spin forever.
+ */
+function startLayout(): void {
+ stopLayout();
+ if (!graph || graph.order === 0) return;
+
+ const settings = forceAtlas2.inferSettings(graph);
+ layout = new FA2Layout(graph, {
+ settings: {
+ ...settings,
+ gravity: 1,
+ scalingRatio: 10,
+ barnesHutOptimize: graph.order > BARNES_HUT_FROM_NODES,
+ },
+ });
+ layout.start();
+
+ layoutTimer = setTimeout(() => {
+ stopLayout();
+ normalizeAndFit();
+ renderer?.refresh();
+ }, LAYOUT_RUN_MS);
+}
+
+function stopLayout(): void {
+ if (layoutTimer !== null) {
+ clearTimeout(layoutTimer);
+ layoutTimer = null;
+ }
+ if (layout) {
+ layout.kill();
+ layout = null;
+ }
+}
+
function normalizeAndFit(): void {
if (!graph || !renderer) return;
const g = graph;
@@ -253,6 +302,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
+ stopLayout();
renderer?.kill();
renderer = null;
graph = null;
diff --git a/admin/slices/reins/components/knowledge/indexStatus/Badge.vue b/admin/slices/reins/components/knowledge/indexStatus/Badge.vue
index 8c228a71..3f6ad314 100644
--- a/admin/slices/reins/components/knowledge/indexStatus/Badge.vue
+++ b/admin/slices/reins/components/knowledge/indexStatus/Badge.vue
@@ -2,9 +2,22 @@
import type { IndexStatus } from '#reins/stores/knowledge';
import { Badge as UiBadge } from '#theme/components/ui/badge';
-const props = defineProps<{ status: IndexStatus }>();
+// `processing` is not a fifth status, it is what the stored one cannot say.
+// A run stops waiting long before LightRAG finishes a large document, so the
+// row reads `ready` while a third of the content is still being chunked. The
+// green "Ready" badge on a base that is only two thirds searchable is what
+// sent people off believing an import had landed. Kept out of `IndexStatus`
+// on purpose: that value also gates the Index button and the 3s poll, and a
+// base waiting on the pipeline must stay indexable - new sources arrive while
+// old ones are still cooking.
+const props = defineProps<{ status: IndexStatus; processing?: boolean }>();
+
+const stillWorking = computed(
+ () => props.processing === true && props.status === 'ready',
+);
const label = computed(() => {
+ if (stillWorking.value) return 'Ready · still indexing';
switch (props.status) {
case 'idle':
return 'Idle';
@@ -25,6 +38,8 @@ const label = computed(() => {
const variant = computed<'default' | 'secondary' | 'destructive' | 'outline'>(
() => {
+ // Deliberately not the green `default`: searchable, but not all of it.
+ if (stillWorking.value) return 'secondary';
switch (props.status) {
case 'ready':
return 'default';
@@ -38,8 +53,14 @@ const variant = computed<'default' | 'secondary' | 'destructive' | 'outline'>(
}
},
);
+
+const title = computed(() =>
+ stillWorking.value
+ ? 'Some documents are still moving through LightRAG. They are confirmed automatically as it finishes.'
+ : undefined,
+);
- {{ label }}
+ {{ label }}
diff --git a/admin/slices/reins/components/knowledge/list/Provider.vue b/admin/slices/reins/components/knowledge/list/Provider.vue
index 60ffbcda..01f5be22 100644
--- a/admin/slices/reins/components/knowledge/list/Provider.vue
+++ b/admin/slices/reins/components/knowledge/list/Provider.vue
@@ -125,7 +125,7 @@ async function onRemove(item: IKnowledge) {