diff --git a/app/plugins/builtins/graph_rag/plugin.py b/app/plugins/builtins/graph_rag/plugin.py index e46329e..e9f45db 100644 --- a/app/plugins/builtins/graph_rag/plugin.py +++ b/app/plugins/builtins/graph_rag/plugin.py @@ -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", diff --git a/frontend/src/__tests__/pages/GraphRag.test.tsx b/frontend/src/__tests__/pages/GraphRag.test.tsx new file mode 100644 index 0000000..5d0286f --- /dev/null +++ b/frontend/src/__tests__/pages/GraphRag.test.tsx @@ -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 => ({ + 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(); + 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( + + + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/api/knowledge.ts b/frontend/src/api/knowledge.ts index 5fb095c..3c41db8 100644 --- a/frontend/src/api/knowledge.ts +++ b/frontend/src/api/knowledge.ts @@ -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 | 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 | 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(`/graph/relationships${qs ? `?${qs}` : ''}`); } +export async function createGraphRelationship( + data: GraphRelationshipCreate, +): Promise { + return apiPost('/graph/relationships', data); +} + +export async function deleteGraphRelationship(relationshipId: string): Promise { + await apiDelete(`/graph/relationships/${relationshipId}`); +} + +export async function traverseGraph( + body: GraphTraverseRequest, +): Promise { + return apiPost('/graph/traverse', body); +} + // ─── Ask Knowledge API ───────────────────────────────────────────────────── export async function askKnowledge(body: KnowledgeAskRequest): Promise { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4ecb7eb..c2ffe4d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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> = { @@ -26,7 +26,7 @@ const ICON_MAP: Record> = { 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'; diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 91dad00..8048802 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -43,6 +43,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/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 })), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 808d89e..91d4013 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0fe1990..24229df 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/pages/GraphRag.tsx b/frontend/src/pages/GraphRag.tsx new file mode 100644 index 0000000..f8c0eeb --- /dev/null +++ b/frontend/src/pages/GraphRag.tsx @@ -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 = { + 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(null); + const [traverseError, setTraverseError] = useState(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(); + 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 ( +
+
+
+
+ {canWrite && ( + + )} +
+ + {/* ── Traversal panel ── */} + +

+ {t('graphRag.traverseTitle')} +

+
+
+ setSrcType(e.target.value)} + list="graph-entity-types" + maxLength={50} + data-testid="graph-traverse-source-type" + /> +
+
+ setSrcId(e.target.value)} + placeholder="00000000-0000-0000-0000-000000000000" + data-testid="graph-traverse-source-id" + /> +
+
+ setMaxHops(e.target.value)} + data-testid="graph-traverse-max-hops" + /> +
+
+ setRelTypeFilter(e.target.value)} + placeholder="works_for, has_email" + data-testid="graph-traverse-type-filter" + /> +
+
+ + {ENTITY_TYPE_SUGGESTIONS.map((s) => ( + +
+ + {traverseResult && ( + + {t('graphRag.stats', { + nodes: traverseResult.total_nodes, + edges: traverseResult.total_edges, + })} + + )} +
+ {traverseError && ( +

+ {traverseError} +

+ )} + + {/* Traversal results grouped by depth */} + {nodesByDepth.length > 0 && ( +
+ {nodesByDepth.map(([depth, nodes]) => ( +
+

+ {t('graphRag.depth', { depth })} ({nodes.length}) +

+
+ {nodes.map((n) => ( + + {n.entity_type} + {n.entity_id.slice(0, 8)}… + + ))} +
+
+ ))} + {traverseResult && traverseResult.edges.length > 0 && ( +
+ + {t('graphRag.edgesCount', { count: traverseResult.edges.length })} + +
    + {traverseResult.edges.slice(0, 50).map((e, i) => ( +
  • + {e.source_type}:{e.source_id.slice(0, 8)}… +
  • + ))} +
+
+ )} +
+ )} +
+ + {/* ── Relationships list ── */} +
+
+

+ {t('graphRag.relationshipsTitle')} + {total > 0 && ({total})} +

+
+ { + setListTypeFilter(e.target.value); + setPage(1); + }} + placeholder={t('graphRag.typeFilter')} + aria-label={t('graphRag.typeFilter')} + data-testid="graph-list-type-filter" + /> +
+
+ + {!canRead ? ( + + + ) : listQuery.isLoading ? ( +
+
+ ) : listQuery.isError ? ( + + + ) : items.length === 0 ? ( + + + ) : ( +
+ {items.map((rel) => ( + +
+
+ {rel.source_type} + :{rel.source_id.slice(0, 8)}… +
+ {canWrite && ( + + )} +
+
+ ))} + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} +
+ )} +
+ + {/* ── Create modal ── */} + setShowCreate(false)} title={t('graphRag.createTitle')} size="lg"> +
+
+ setForm((f) => ({ ...f, source_type: e.target.value }))} + list="graph-entity-types" + maxLength={50} + data-testid="graph-form-source-type" + /> + setForm((f) => ({ ...f, source_id: e.target.value }))} + placeholder="00000000-0000-0000-0000-000000000000" + data-testid="graph-form-source-id" + /> + setForm((f) => ({ ...f, target_type: e.target.value }))} + list="graph-entity-types" + maxLength={50} + data-testid="graph-form-target-type" + /> + setForm((f) => ({ ...f, target_id: e.target.value }))} + placeholder="00000000-0000-0000-0000-000000000000" + data-testid="graph-form-target-id" + /> +
+ 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 && ( +

+ {createMut.error instanceof Error ? createMut.error.message : t('graphRag.createError')} +

+ )} +
+ + +
+
+
+
+ ); +}