Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ api/docker/
!.env.example
.envrc
swagger-spec.json
worktrees/

# Claude Code subagent scratch
.claude/
Expand Down
4 changes: 2 additions & 2 deletions admin/slices/agent/agent/components/agent/knowledge/Tab.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}`)"
>
<TableCell class="font-medium">{{ k.name }}</TableCell>
<TableCell class="max-w-md truncate text-muted-foreground">
Expand All@@ -167,7 +167,7 @@ function unbindMissing(id: string): void {
<TableCell @click.stop>
<div class="flex justify-end gap-2">
<Button size="sm" variant="outline" as-child>
<NuxtLink :to="`/knowledges/${k.id}/edit`">Open</NuxtLink>
<NuxtLink :to="`/knowledges/${k.id}`">Open</NuxtLink>
</Button>
</div>
</TableCell>
Expand Down
34 changes: 34 additions & 0 deletions admin/slices/reins/components/knowledge/SourceStatusBadge.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Badge } from '#theme/components/ui/badge';
import type { SourceIndexStatus } from '#reins/stores/knowledge';

const props = defineProps<{ status: SourceIndexStatus }>();

const label = computed(() => {
switch (props.status) {
case 'indexed':
return 'Indexed';
case 'failed':
return 'Failed';
case 'pending':
return 'Pending';
default:
return props.status;
}
});

const variant = computed<'default' | 'destructive' | 'outline'>(() => {
switch (props.status) {
case 'indexed':
return 'default';
case 'failed':
return 'destructive';
default:
return 'outline';
}
});
</script>

<template>
<Badge :variant="variant">{{ label }}</Badge>
</template>
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
}
Expand Down
49 changes: 0 additions & 49 deletions admin/slices/reins/components/knowledge/edit/Provider.vue

This file was deleted.

144 changes: 144 additions & 0 deletions admin/slices/reins/components/knowledge/graph/LabelPicker.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core';

/**
* Entity picker for the graph.
*
* This replaces a plain `<Select>` fed the raw label list, which is what froze
* the tab. `/graph/label/list` returns every entity in the whole index - tens
* of thousands of them once a real corpus is indexed - and the select mounted a
* DOM node for each one on open. Nothing here renders more than MAX_VISIBLE
* rows no matter how long the list is; the rest is reached by typing.
*/
const ALL = '*';
const ALL_LABEL = '* (all entities)';
const MAX_VISIBLE = 50;

const props = defineProps<{
modelValue: string;
labels: string[];
loading?: boolean;
}>();

const emit = defineEmits<{ 'update:modelValue': [value: string] }>();

const root = ref<HTMLElement | null>(null);
const open = ref(false);
const query = ref('');

function displayFor(value: string): string {
return value === ALL ? ALL_LABEL : value;
}

// Matching is done against a lowercased copy built once per label list rather
// than per keystroke: on 50k entries the difference is the whole point.
const lowerLabels = computed(() => props.labels.map((l) => l.toLowerCase()));

const filtered = computed<string[]>(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.labels;
const lower = lowerLabels.value;
const found: string[] = [];
for (let i = 0; i < lower.length; i += 1) {
if (lower[i].includes(q)) found.push(props.labels[i]);
}
return found;
});

const visible = computed(() => filtered.value.slice(0, MAX_VISIBLE));
const hiddenCount = computed(() =>
Math.max(0, filtered.value.length - MAX_VISIBLE),
);

// "* (all)" stays reachable whatever is typed: it is the default view and not
// an entity in the list.
const showAllOption = computed(() => {
const q = query.value.trim().toLowerCase();
return q === '' || ALL_LABEL.includes(q) || q === ALL;
});

function pick(value: string): void {
emit('update:modelValue', value);
query.value = displayFor(value);
open.value = false;
}

function focus(): void {
open.value = true;
// Clearing on focus makes the whole list browsable again; otherwise the
// current selection would filter it down to itself.
query.value = '';
}

function close(): void {
if (!open.value) return;
open.value = false;
query.value = displayFor(props.modelValue);
}

watch(
() => props.modelValue,
(value) => {
if (!open.value) query.value = displayFor(value);
},
{ immediate: true },
);

onClickOutside(root, close);
</script>

<template>
<div ref="root" class="relative">
<Input
:model-value="query"
:placeholder="loading ? 'Loading entities…' : 'Search entities…'"
:disabled="loading"
class="w-64"
role="combobox"
:aria-expanded="open"
@focus="focus"
@keydown.esc="close"
@update:model-value="(v: string | number) => { query = String(v); open = true; }"
/>

<div
v-if="open"
class="absolute z-50 mt-1 max-h-72 w-64 overflow-y-auto rounded-md border bg-popover p-1 shadow-md"
>
<button
v-if="showAllOption"
type="button"
class="w-full rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
:class="modelValue === ALL ? 'bg-accent font-medium' : ''"
@click="pick(ALL)"
>
{{ ALL_LABEL }}
</button>

<button
v-for="label in visible"
:key="label"
type="button"
class="w-full truncate rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
:class="modelValue === label ? 'bg-accent font-medium' : ''"
:title="label"
@click="pick(label)"
>
{{ label }}
</button>

<p
v-if="hiddenCount"
class="px-2 py-1.5 text-xs text-muted-foreground"
>
{{ hiddenCount }} more - keep typing to narrow it down
</p>
<p
v-else-if="!visible.length && !showAllOption"
class="px-2 py-1.5 text-xs text-muted-foreground"
>
Nothing matches "{{ query }}"
</p>
</div>
</div>
</template>
78 changes: 64 additions & 14 deletions admin/slices/reins/components/knowledge/graph/Provider.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -36,8 +37,17 @@ const selectedNode = ref<GraphNodeDto | null>(null);
const selectedEdge = ref<GraphEdgeDto | null>(null);
const nodes = ref<GraphNodeDto[]>([]);

// 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<typeof setTimeout> | null = null;
const nodeIndex = new Map<string, GraphNodeDto>();
const edgeIndex = new Map<string, GraphEdgeDto>();

Expand DownExpand Up@@ -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();
Expand All@@ -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;
Expand All@@ -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;
Expand DownExpand Up@@ -253,6 +302,7 @@ onMounted(async () => {
});

onBeforeUnmount(() => {
stopLayout();
renderer?.kill();
renderer = null;
graph = null;
Expand Down
Loading
Loading