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
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user