feat(graph-rag): UI fuer Wissens-Graph mit BFS-Traversierung — Modul 11/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-09-15 08:25:21 +02:00
parent 4eba9eb1d9
commit 0404c8f5dc
8 changed files with 876 additions and 5 deletions
+25 -1
View File
@@ -3,7 +3,13 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import MiniAppContribution, PluginManifest, PluginRouteDef
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
class GraphRAGPlugin(BasePlugin):
@@ -43,6 +49,24 @@ class GraphRAGPlugin(BasePlugin):
order=90,
),
],
menu_items=[
FrontendMenuItem(
label_key="nav.graphRag",
label="Wissens-Graph",
path="/graph-rag",
icon="Share2",
order=87,
permission="graph:read",
),
],
page_routes=[
FrontendPageRoute(
path="/graph-rag",
component="@/pages/GraphRag",
protected=True,
permission="graph:read",
),
],
permissions=[
"graph:read",
"graph:write",
@@ -0,0 +1,257 @@
/**
* GraphRAG page tests — knowledge graph traversal and relationship
* management UI (UI-Backlog module 11/16).
*
* Covers: rendering, permission gating, traverse flow (payload with
* max_hops clamp + relationship_types parsing), traverse results grouped
* by depth, relationships list, create modal validation, delete flow.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GraphRagPage } from '@/pages/GraphRag';
import type { GraphRelationship, GraphTraverseResult } from '@/api/knowledge';
const { createMut, deleteMut, traverseMut } = vi.hoisted(() => ({
createMut: vi.fn().mockResolvedValue({}),
deleteMut: vi.fn().mockResolvedValue({}),
traverseMut: vi.fn().mockResolvedValue({}),
}));
const makeRel = (overrides: Partial<GraphRelationship> = {}): GraphRelationship => ({
id: '11111111-1111-1111-1111-111111111111',
source_type: 'contact',
source_id: '22222222-2222-2222-2222-222222222222',
target_type: 'file',
target_id: '33333333-3333-3333-3333-333333333333',
relationship_type: 'has_document',
metadata: null,
owner_id: null,
created_at: '2026-09-01T10:00:00Z',
...overrides,
});
const makeTraverseResult = (): GraphTraverseResult => ({
nodes: [
{ entity_type: 'contact', entity_id: '22222222-2222-2222-2222-222222222222', depth: 0, path: [] },
{ entity_type: 'file', entity_id: '33333333-3333-3333-3333-333333333333', depth: 1, path: ['22222222'] },
],
edges: [
{
source_type: 'contact',
source_id: '22222222-2222-2222-2222-222222222222',
target_type: 'file',
target_id: '33333333-3333-3333-3333-333333333333',
relationship_type: 'has_document',
metadata: null,
},
],
total_nodes: 2,
total_edges: 1,
});
let mockItems: GraphRelationship[] = [];
let mockTotal = 0;
let mockCanRead = true;
let mockCanWrite = true;
vi.mock('@/api/knowledge', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/knowledge')>();
return {
...actual,
fetchGraphRelationships: vi.fn().mockImplementation(async (params) => {
const page = params?.page ?? 1;
const pageSize = params?.page_size ?? 20;
const start = (page - 1) * pageSize;
return {
items: mockItems.slice(start, start + pageSize),
total: mockTotal,
};
}),
createGraphRelationship: createMut,
deleteGraphRelationship: deleteMut,
traverseGraph: traverseMut,
};
});
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) =>
(mockCanRead || perm !== 'graph:read') &&
(mockCanWrite || perm !== 'graph:write'),
}),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<GraphRagPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
// NOTE: do NOT use vi.clearAllMocks() — it strips the mockImplementation
// from fetchGraphRelationships. Reset call history only.
createMut.mockClear();
deleteMut.mockClear();
traverseMut.mockClear();
mockItems = [];
mockTotal = 0;
mockCanRead = true;
mockCanWrite = true;
// Direct override: spyOn(window, 'confirm') is unreliable in jsdom after
// mock resets — a plain function assignment always returns true.
window.confirm = () => true;
});
describe('GraphRagPage', () => {
it('renders page with traverse panel and relationships section', async () => {
renderPage();
expect(screen.getByTestId('graph-rag-page')).toBeInTheDocument();
expect(screen.getByTestId('graph-traverse-panel')).toBeInTheDocument();
// empty state appears after the async relationships query resolves
expect(await screen.findByTestId('graph-empty')).toBeInTheDocument();
});
it('shows no-permission card when graph:read is missing', () => {
mockCanRead = false;
renderPage();
expect(screen.getByTestId('graph-no-permission')).toBeInTheDocument();
});
it('hides create button without graph:write', () => {
mockCanWrite = false;
renderPage();
expect(screen.queryByTestId('graph-create-btn')).not.toBeInTheDocument();
});
it('renders relationships with source → type → target display', async () => {
mockItems = [makeRel()];
mockTotal = 1;
renderPage();
// fetchGraphRelationships (queryFn) resolves async — wait for the card
expect(await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111')).toBeInTheDocument();
expect(screen.getByText('has_document')).toBeInTheDocument();
});
it('hides delete button without graph:write', async () => {
mockItems = [makeRel()];
mockTotal = 1;
mockCanWrite = false;
renderPage();
await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111');
expect(
screen.queryByTestId('graph-rel-delete-11111111-1111-1111-1111-111111111111'),
).not.toBeInTheDocument();
});
it('traverses with clamped max_hops and parsed relationship_types', async () => {
traverseMut.mockResolvedValue(makeTraverseResult());
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-type'), {
target: { value: 'contact' },
});
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
// out-of-range value clamps to 10
fireEvent.change(screen.getByTestId('graph-traverse-max-hops'), {
target: { value: '99' },
});
fireEvent.change(screen.getByTestId('graph-traverse-type-filter'), {
target: { value: 'has_document, works_for' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => expect(traverseMut).toHaveBeenCalled());
const payload = traverseMut.mock.calls[0][0];
expect(payload.source_type).toBe('contact');
expect(payload.source_id).toBe('22222222-2222-2222-2222-222222222222');
expect(payload.max_hops).toBe(10);
expect(payload.relationship_types).toEqual(['has_document', 'works_for']);
});
it('sends null relationship_types when filter is empty', async () => {
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => expect(traverseMut).toHaveBeenCalled());
expect(traverseMut.mock.calls[0][0].relationship_types).toBeNull();
});
it('displays traverse results grouped by depth with stats', async () => {
traverseMut.mockResolvedValue(makeTraverseResult());
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => {
expect(screen.getByTestId('graph-traverse-results')).toBeInTheDocument();
});
expect(screen.getByTestId('graph-traverse-stats')).toHaveTextContent('2');
// depth 0 node and depth 1 node rendered
expect(screen.getByTestId('graph-node-contact-22222222')).toBeInTheDocument();
expect(screen.getByTestId('graph-node-file-33333333')).toBeInTheDocument();
});
it('opens create modal, validates required fields, and submits payload', async () => {
renderPage();
fireEvent.click(screen.getByTestId('graph-create-btn'));
// submit disabled while fields empty
expect(screen.getByTestId('graph-form-submit')).toBeDisabled();
fireEvent.change(screen.getByTestId('graph-form-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.change(screen.getByTestId('graph-form-target-id'), {
target: { value: '33333333-3333-3333-3333-333333333333' },
});
fireEvent.change(screen.getByTestId('graph-form-rel-type'), {
target: { value: 'has_document' },
});
fireEvent.click(screen.getByTestId('graph-form-submit'));
await waitFor(() => expect(createMut).toHaveBeenCalled());
const payload = createMut.mock.calls[0][0];
expect(payload.source_type).toBe('contact');
expect(payload.source_id).toBe('22222222-2222-2222-2222-222222222222');
expect(payload.target_type).toBe('file');
expect(payload.target_id).toBe('33333333-3333-3333-3333-333333333333');
expect(payload.relationship_type).toBe('has_document');
});
it('deletes a relationship after confirm', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockItems = [makeRel()];
mockTotal = 1;
renderPage();
await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111');
fireEvent.click(screen.getByTestId('graph-rel-delete-11111111-1111-1111-1111-111111111111'));
// TanStack Query v5 calls mutationFn(id, context) — assert the first arg.
await waitFor(() => expect(deleteMut).toHaveBeenCalled(), { timeout: 3000 });
expect(deleteMut.mock.calls[0][0]).toBe('11111111-1111-1111-1111-111111111111');
expect(confirmSpy).toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('traverse submit disabled without source id', () => {
renderPage();
expect(screen.getByTestId('graph-traverse-submit')).toBeDisabled();
});
});
+55
View File
@@ -104,6 +104,45 @@ export interface GraphRelationshipsResult {
total: number;
}
export interface GraphRelationshipCreate {
source_type: string;
source_id: string;
target_type: string;
target_id: string;
relationship_type: string;
metadata?: Record<string, unknown> | null;
}
export interface GraphNode {
entity_type: string;
entity_id: string;
depth: number;
path: string[];
}
export interface GraphEdge {
source_type: string;
source_id: string;
target_type: string;
target_id: string;
relationship_type: string;
metadata: Record<string, unknown> | null;
}
export interface GraphTraverseRequest {
source_type: string;
source_id: string;
max_hops?: number;
relationship_types?: string[] | null;
}
export interface GraphTraverseResult {
nodes: GraphNode[];
edges: GraphEdge[];
total_nodes: number;
total_edges: number;
}
// ─── Ask Knowledge types ───────────────────────────────────────────────────
export interface KnowledgeAskRequest {
@@ -203,6 +242,22 @@ export async function fetchGraphRelationships(params?: {
return apiGet<GraphRelationshipsResult>(`/graph/relationships${qs ? `?${qs}` : ''}`);
}
export async function createGraphRelationship(
data: GraphRelationshipCreate,
): Promise<GraphRelationship> {
return apiPost<GraphRelationship>('/graph/relationships', data);
}
export async function deleteGraphRelationship(relationshipId: string): Promise<void> {
await apiDelete<void>(`/graph/relationships/${relationshipId}`);
}
export async function traverseGraph(
body: GraphTraverseRequest,
): Promise<GraphTraverseResult> {
return apiPost<GraphTraverseResult>('/graph/traverse', body);
}
// ─── Ask Knowledge API ─────────────────────────────────────────────────────
export async function askKnowledge(body: KnowledgeAskRequest): Promise<KnowledgeAskResponse> {
+2 -2
View File
@@ -14,7 +14,7 @@ import {
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
BookOpen, Lightbulb,
BookOpen, Lightbulb, Share2,
} from 'lucide-react';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -26,7 +26,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock,
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
Brain, Store, Tags, ArrowRightLeft,
Brain, Store, Tags, ArrowRightLeft, Share2,
};
import { useMenuOrder } from '@/api/users';
import { usePermission } from '@/hooks/usePermission';
@@ -43,6 +43,7 @@ export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then((m) => ({ default: m.DmsTrashPage })),
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then((m) => ({ default: m.GlobalSearchResultsPage })),
'@/pages/GraphRag': () => import('@/pages/GraphRag').then((m) => ({ default: m.GraphRagPage })),
'@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })),
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
+30 -1
View File
@@ -27,7 +27,8 @@
"outbox": "Outbox",
"marketplace": "Marketplace",
"skills": "Skills",
"agentMemory": "Agent Memory"
"agentMemory": "Agent Memory",
"graphRag": "Wissens-Graph"
},
"auth": {
"login": "Anmelden",
@@ -1854,6 +1855,34 @@
"empty": "Keine Memories fuer diesen Agenten.",
"loadError": "Memories konnten nicht geladen werden."
},
"graphRag": {
"title": "Wissens-Graph",
"traverseTitle": "Graph-Traversierung",
"sourceType": "Entitätstyp (Start)",
"sourceId": "Entitäts-ID (Start)",
"maxHops": "Max. Hops",
"typeFilter": "Beziehungstypen (Komma)",
"traverse": "Traversieren",
"traversing": "Traversiere…",
"stats": "{{nodes}} Knoten · {{edges}} Kanten",
"depth": "Tiefe {{depth}}",
"edgesCount": "{{count}} Kanten anzeigen",
"relationshipsTitle": "Beziehungen",
"create": "Beziehung erstellen",
"createTitle": "Neue Beziehung",
"createSubmit": "Erstellen",
"createError": "Beziehung konnte nicht erstellt werden.",
"targetType": "Entitätstyp (Ziel)",
"targetId": "Entitäts-ID (Ziel)",
"relationshipType": "Beziehungstyp",
"delete": "Löschen",
"deleteConfirm": "Beziehung \"{{type}}\" wirklich löschen?",
"prev": "Zurück",
"next": "Weiter",
"empty": "Keine Beziehungen vorhanden.",
"noPermission": "Keine Berechtigung (graph:read erforderlich).",
"loadError": "Graph-Daten konnten nicht geladen werden."
},
"policies": {
"title": "ABAC-Richtlinien",
"create": "Richtlinie erstellen",
+30 -1
View File
@@ -27,7 +27,8 @@
"outbox": "Outbox",
"marketplace": "Marketplace",
"skills": "Skills",
"agentMemory": "Agent Memory"
"agentMemory": "Agent Memory",
"graphRag": "Knowledge Graph"
},
"auth": {
"login": "Sign In",
@@ -1854,6 +1855,34 @@
"empty": "No memories for this agent.",
"loadError": "Failed to load memories."
},
"graphRag": {
"title": "Knowledge Graph",
"traverseTitle": "Graph traversal",
"sourceType": "Entity type (source)",
"sourceId": "Entity ID (source)",
"maxHops": "Max hops",
"typeFilter": "Relationship types (comma)",
"traverse": "Traverse",
"traversing": "Traversing…",
"stats": "{{nodes}} nodes · {{edges}} edges",
"depth": "Depth {{depth}}",
"edgesCount": "Show {{count}} edges",
"relationshipsTitle": "Relationships",
"create": "Create relationship",
"createTitle": "New relationship",
"createSubmit": "Create",
"createError": "Failed to create relationship.",
"targetType": "Entity type (target)",
"targetId": "Entity ID (target)",
"relationshipType": "Relationship type",
"delete": "Delete",
"deleteConfirm": "Delete relationship \"{{type}}\"?",
"prev": "Previous",
"next": "Next",
"empty": "No relationships yet.",
"noPermission": "No permission (graph:read required).",
"loadError": "Failed to load graph data."
},
"policies": {
"title": "ABAC Policies",
"create": "Create policy",
+476
View File
@@ -0,0 +1,476 @@
/**
* GraphRAG page — knowledge graph traversal and relationship management
* (UI-Backlog module 11/16).
*
* Backend: /api/v1/graph (graph_rag plugin)
* - POST /traverse → BFS traversal (max_hops 1-10, type filter)
* - GET /relationships → paginated list with filters
* - POST /relationships → create relationship (409 on duplicate)
* - DELETE /relationships/{id} → delete relationship
* Permissions: graph:read (traverse, list) / graph:write (create, delete).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Share2,
Plus,
Trash2,
Inbox,
AlertTriangle,
Search,
ChevronRight,
} from 'lucide-react';
import {
fetchGraphRelationships,
createGraphRelationship,
deleteGraphRelationship,
traverseGraph,
type GraphRelationship,
type GraphTraverseResult,
type GraphNode,
} from '@/api/knowledge';
import { usePermission } from '@/hooks/usePermission';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
const PAGE_SIZE = 20;
const ENTITY_TYPE_SUGGESTIONS = [
'contact', 'company', 'file', 'task', 'calendar_event',
'mail_message', 'workflow', 'wiki_article', 'deal',
];
const DEPTH_BORDER: Record<number, string> = {
0: 'border-primary-300 dark:border-primary-700',
1: 'border-secondary-300 dark:border-secondary-600',
2: 'border-secondary-200 dark:border-secondary-700',
};
function nodeKey(n: GraphNode): string {
return `${n.entity_type}:${n.entity_id}`;
}
export function GraphRagPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { hasPermission } = usePermission();
const canRead = hasPermission('graph:read');
const canWrite = hasPermission('graph:write');
// ── Traverse state ──
const [srcType, setSrcType] = useState('contact');
const [srcId, setSrcId] = useState('');
const [maxHops, setMaxHops] = useState('3');
const [relTypeFilter, setRelTypeFilter] = useState('');
const [traverseResult, setTraverseResult] = useState<GraphTraverseResult | null>(null);
const [traverseError, setTraverseError] = useState<string | null>(null);
// ── Relationships state ──
const [page, setPage] = useState(1);
const [listTypeFilter, setListTypeFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
source_type: 'contact',
source_id: '',
target_type: 'file',
target_id: '',
relationship_type: '',
});
const listQuery = useQuery({
queryKey: ['graphRelationships', page, listTypeFilter],
queryFn: () =>
fetchGraphRelationships({
page,
page_size: PAGE_SIZE,
...(listTypeFilter ? { relationship_type: listTypeFilter } : {}),
}),
enabled: canRead,
});
const createMut = useMutation({
mutationFn: createGraphRelationship,
onSuccess: () => {
setShowCreate(false);
qc.invalidateQueries({ queryKey: ['graphRelationships'] });
},
});
const deleteMut = useMutation({
mutationFn: deleteGraphRelationship,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['graphRelationships'] });
},
});
const traverseMut = useMutation({
mutationFn: traverseGraph,
onSuccess: (result) => {
setTraverseResult(result);
setTraverseError(null);
},
onError: (err) => {
setTraverseError(err instanceof Error ? err.message : 'Traversal failed');
},
});
const runTraverse = () => {
if (!srcId.trim() || !srcType.trim()) return;
const types = relTypeFilter
.split(',')
.map((s) => s.trim())
.filter(Boolean);
traverseMut.mutate({
source_type: srcType.trim(),
source_id: srcId.trim(),
max_hops: Math.max(1, Math.min(10, parseInt(maxHops, 10) || 3)),
relationship_types: types.length > 0 ? types : null,
});
};
const submitCreate = () => {
if (!form.source_id.trim() || !form.target_id.trim() || !form.relationship_type.trim()) return;
createMut.mutate({
source_type: form.source_type.trim(),
source_id: form.source_id.trim(),
target_type: form.target_type.trim(),
target_id: form.target_id.trim(),
relationship_type: form.relationship_type.trim(),
});
};
const handleDelete = (rel: GraphRelationship) => {
if (window.confirm(t('graphRag.deleteConfirm', { type: rel.relationship_type }))) {
deleteMut.mutate(rel.id);
}
};
// Group traverse nodes by depth for display
const nodesByDepth = React.useMemo(() => {
if (!traverseResult) return [];
const map = new Map<number, GraphNode[]>();
for (const n of traverseResult.nodes) {
const arr = map.get(n.depth) ?? [];
arr.push(n);
map.set(n.depth, arr);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [traverseResult]);
const items = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const formValid =
form.source_type.trim() &&
form.source_id.trim() &&
form.target_type.trim() &&
form.target_id.trim() &&
form.relationship_type.trim();
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6" data-testid="graph-rag-page">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Share2 className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('graphRag.title')}
</h1>
</div>
{canWrite && (
<Button onClick={() => setShowCreate(true)} data-testid="graph-create-btn">
<Plus className="w-4 h-4" aria-hidden="true" />
{t('graphRag.create')}
</Button>
)}
</div>
{/* ── Traversal panel ── */}
<Card className="p-4 space-y-4" data-testid="graph-traverse-panel">
<h2 className="text-base font-semibold text-secondary-900 dark:text-secondary-100">
{t('graphRag.traverseTitle')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
<div>
<Input
label={t('graphRag.sourceType')}
value={srcType}
onChange={(e) => setSrcType(e.target.value)}
list="graph-entity-types"
maxLength={50}
data-testid="graph-traverse-source-type"
/>
</div>
<div>
<Input
label={t('graphRag.sourceId')}
value={srcId}
onChange={(e) => setSrcId(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-traverse-source-id"
/>
</div>
<div>
<Input
label={t('graphRag.maxHops')}
type="number"
min={1}
max={10}
value={maxHops}
onChange={(e) => setMaxHops(e.target.value)}
data-testid="graph-traverse-max-hops"
/>
</div>
<div>
<Input
label={t('graphRag.typeFilter')}
value={relTypeFilter}
onChange={(e) => setRelTypeFilter(e.target.value)}
placeholder="works_for, has_email"
data-testid="graph-traverse-type-filter"
/>
</div>
</div>
<datalist id="graph-entity-types">
{ENTITY_TYPE_SUGGESTIONS.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<div className="flex items-center gap-3">
<Button
onClick={runTraverse}
disabled={!canRead || !srcId.trim() || traverseMut.isPending}
data-testid="graph-traverse-submit"
>
<Search className="w-4 h-4" aria-hidden="true" />
{traverseMut.isPending ? t('graphRag.traversing') : t('graphRag.traverse')}
</Button>
{traverseResult && (
<span className="text-xs text-secondary-500" data-testid="graph-traverse-stats">
{t('graphRag.stats', {
nodes: traverseResult.total_nodes,
edges: traverseResult.total_edges,
})}
</span>
)}
</div>
{traverseError && (
<p className="text-sm text-danger-600 dark:text-danger-400" data-testid="graph-traverse-error">
{traverseError}
</p>
)}
{/* Traversal results grouped by depth */}
{nodesByDepth.length > 0 && (
<div className="space-y-3 pt-2 border-t border-secondary-200 dark:border-secondary-700" data-testid="graph-traverse-results">
{nodesByDepth.map(([depth, nodes]) => (
<div key={depth}>
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-1">
{t('graphRag.depth', { depth })} ({nodes.length})
</h3>
<div className="flex flex-wrap gap-2">
{nodes.map((n) => (
<span
key={nodeKey(n)}
className={clsx(
'inline-flex items-center gap-1 px-2 py-1 rounded-md border text-xs font-mono bg-white dark:bg-secondary-800',
DEPTH_BORDER[Math.min(depth, 2)] ?? DEPTH_BORDER[2],
)}
title={n.path.join(' → ')}
data-testid={`graph-node-${n.entity_type}-${n.entity_id.slice(0, 8)}`}
>
<span className="font-semibold">{n.entity_type}</span>
<span className="text-secondary-400">{n.entity_id.slice(0, 8)}</span>
</span>
))}
</div>
</div>
))}
{traverseResult && traverseResult.edges.length > 0 && (
<details className="text-xs text-secondary-600 dark:text-secondary-400">
<summary className="cursor-pointer font-medium">
{t('graphRag.edgesCount', { count: traverseResult.edges.length })}
</summary>
<ul className="mt-2 space-y-1 font-mono">
{traverseResult.edges.slice(0, 50).map((e, i) => (
<li key={i} className="break-all">
{e.source_type}:{e.source_id.slice(0, 8)}
<ChevronRight className="inline w-3 h-3 mx-0.5" aria-hidden="true" />
<span className="font-semibold">{e.relationship_type}</span>
<ChevronRight className="inline w-3 h-3 mx-0.5" aria-hidden="true" />
{e.target_type}:{e.target_id.slice(0, 8)}
</li>
))}
</ul>
</details>
)}
</div>
)}
</Card>
{/* ── Relationships list ── */}
<section aria-labelledby="graph-rel-heading">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-2">
<h2
id="graph-rel-heading"
className="text-base font-semibold text-secondary-900 dark:text-secondary-100"
>
{t('graphRag.relationshipsTitle')}
{total > 0 && <span className="ml-2 text-xs font-normal text-secondary-500">({total})</span>}
</h2>
<div className="w-48">
<Input
value={listTypeFilter}
onChange={(e) => {
setListTypeFilter(e.target.value);
setPage(1);
}}
placeholder={t('graphRag.typeFilter')}
aria-label={t('graphRag.typeFilter')}
data-testid="graph-list-type-filter"
/>
</div>
</div>
{!canRead ? (
<Card className="p-8 text-center" data-testid="graph-no-permission">
<AlertTriangle className="w-8 h-8 mx-auto text-warning-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('graphRag.noPermission')}</p>
</Card>
) : listQuery.isLoading ? (
<div className="flex items-center justify-center py-12" role="status">
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
</div>
) : listQuery.isError ? (
<Card className="p-8 text-center">
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('graphRag.loadError')}</p>
</Card>
) : items.length === 0 ? (
<Card className="p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
<p className="mt-3 text-sm text-secondary-500" data-testid="graph-empty">
{t('graphRag.empty')}
</p>
</Card>
) : (
<div className="space-y-2">
{items.map((rel) => (
<Card key={rel.id} className="p-3" data-testid={`graph-rel-${rel.id}`}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 text-xs font-mono break-all">
<span className="font-semibold">{rel.source_type}</span>
<span className="text-secondary-400">:{rel.source_id.slice(0, 8)}</span>
<ChevronRight className="inline w-3 h-3 mx-1 text-primary-500" aria-hidden="true" />
<span className="font-semibold text-primary-600 dark:text-primary-400">
{rel.relationship_type}
</span>
<ChevronRight className="inline w-3 h-3 mx-1 text-primary-500" aria-hidden="true" />
<span className="font-semibold">{rel.target_type}</span>
<span className="text-secondary-400">:{rel.target_id.slice(0, 8)}</span>
</div>
{canWrite && (
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(rel)}
disabled={deleteMut.isPending}
aria-label={t('graphRag.delete')}
data-testid={`graph-rel-delete-${rel.id}`}
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-center gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
{t('graphRag.prev')}
</Button>
<span className="text-sm text-secondary-500 self-center">{page} / {totalPages}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
{t('graphRag.next')}
</Button>
</div>
)}
</div>
)}
</section>
{/* ── Create modal ── */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={t('graphRag.createTitle')} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label={t('graphRag.sourceType')}
value={form.source_type}
onChange={(e) => setForm((f) => ({ ...f, source_type: e.target.value }))}
list="graph-entity-types"
maxLength={50}
data-testid="graph-form-source-type"
/>
<Input
label={t('graphRag.sourceId')}
value={form.source_id}
onChange={(e) => setForm((f) => ({ ...f, source_id: e.target.value }))}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-form-source-id"
/>
<Input
label={t('graphRag.targetType')}
value={form.target_type}
onChange={(e) => setForm((f) => ({ ...f, target_type: e.target.value }))}
list="graph-entity-types"
maxLength={50}
data-testid="graph-form-target-type"
/>
<Input
label={t('graphRag.targetId')}
value={form.target_id}
onChange={(e) => setForm((f) => ({ ...f, target_id: e.target.value }))}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-form-target-id"
/>
</div>
<Input
label={t('graphRag.relationshipType')}
value={form.relationship_type}
onChange={(e) => setForm((f) => ({ ...f, relationship_type: e.target.value }))}
placeholder="works_for, has_email, relates_to"
maxLength={50}
data-testid="graph-form-rel-type"
/>
{createMut.isError && (
<p className="text-sm text-danger-600 dark:text-danger-400" data-testid="graph-form-error">
{createMut.error instanceof Error ? createMut.error.message : t('graphRag.createError')}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setShowCreate(false)}>
{t('common.cancel')}
</Button>
<Button onClick={submitCreate} disabled={!formValid || createMut.isPending} data-testid="graph-form-submit">
{t('graphRag.createSubmit')}
</Button>
</div>
</div>
</Modal>
</div>
);
}