diff --git a/.gitignore b/.gitignore index ba59b57e..21023dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ api/docker/ !.env.example .envrc swagger-spec.json +worktrees/ # Claude Code subagent scratch .claude/ diff --git a/admin/slices/agent/agent/components/agent/knowledge/Tab.vue b/admin/slices/agent/agent/components/agent/knowledge/Tab.vue index ed37bb4e..51170aa8 100644 --- a/admin/slices/agent/agent/components/agent/knowledge/Tab.vue +++ b/admin/slices/agent/agent/components/agent/knowledge/Tab.vue @@ -152,7 +152,7 @@ function unbindMissing(id: string): void { v-for="k in resolved" :key="k.id" class="cursor-pointer" - @click="navigateTo(`/knowledges/${k.id}/edit`)" + @click="navigateTo(`/knowledges/${k.id}`)" > {{ k.name }} @@ -167,7 +167,7 @@ function unbindMissing(id: string): void {
diff --git a/admin/slices/reins/components/knowledge/SourceStatusBadge.vue b/admin/slices/reins/components/knowledge/SourceStatusBadge.vue new file mode 100644 index 00000000..5cb695ca --- /dev/null +++ b/admin/slices/reins/components/knowledge/SourceStatusBadge.vue @@ -0,0 +1,34 @@ + + + diff --git a/admin/slices/reins/components/knowledge/create/Provider.vue b/admin/slices/reins/components/knowledge/create/Provider.vue index aac841a4..910264a2 100644 --- a/admin/slices/reins/components/knowledge/create/Provider.vue +++ b/admin/slices/reins/components/knowledge/create/Provider.vue @@ -14,7 +14,7 @@ async function onSubmit(values: IKnowledgeFormValues) { description: values.description, }); if (created) { - await navigateTo(`/knowledges/${created.id}/edit`); + await navigateTo(`/knowledges/${created.id}`); } else { await navigateTo('/knowledges'); } diff --git a/admin/slices/reins/components/knowledge/edit/Provider.vue b/admin/slices/reins/components/knowledge/edit/Provider.vue deleted file mode 100644 index 8a992bf7..00000000 --- a/admin/slices/reins/components/knowledge/edit/Provider.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/admin/slices/reins/components/knowledge/graph/LabelPicker.vue b/admin/slices/reins/components/knowledge/graph/LabelPicker.vue new file mode 100644 index 00000000..6362d4a4 --- /dev/null +++ b/admin/slices/reins/components/knowledge/graph/LabelPicker.vue @@ -0,0 +1,144 @@ + + + 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, +); 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) {