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 __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
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):
|
class GraphRAGPlugin(BasePlugin):
|
||||||
@@ -43,6 +49,24 @@ class GraphRAGPlugin(BasePlugin):
|
|||||||
order=90,
|
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=[
|
permissions=[
|
||||||
"graph:read",
|
"graph:read",
|
||||||
"graph:write",
|
"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;
|
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 ───────────────────────────────────────────────────
|
// ─── Ask Knowledge types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface KnowledgeAskRequest {
|
export interface KnowledgeAskRequest {
|
||||||
@@ -203,6 +242,22 @@ export async function fetchGraphRelationships(params?: {
|
|||||||
return apiGet<GraphRelationshipsResult>(`/graph/relationships${qs ? `?${qs}` : ''}`);
|
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 ─────────────────────────────────────────────────────
|
// ─── Ask Knowledge API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function askKnowledge(body: KnowledgeAskRequest): Promise<KnowledgeAskResponse> {
|
export async function askKnowledge(body: KnowledgeAskRequest): Promise<KnowledgeAskResponse> {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
|
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
|
||||||
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
|
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
|
||||||
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
|
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
|
||||||
BookOpen, Lightbulb,
|
BookOpen, Lightbulb, Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
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,
|
MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock,
|
||||||
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
|
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
|
||||||
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
|
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
|
||||||
Brain, Store, Tags, ArrowRightLeft,
|
Brain, Store, Tags, ArrowRightLeft, Share2,
|
||||||
};
|
};
|
||||||
import { useMenuOrder } from '@/api/users';
|
import { useMenuOrder } from '@/api/users';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
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/DmsTrash': () => import('@/pages/DmsTrash').then((m) => ({ default: m.DmsTrashPage })),
|
||||||
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })),
|
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })),
|
||||||
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then((m) => ({ default: m.GlobalSearchResultsPage })),
|
'@/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/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })),
|
||||||
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
|
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
|
||||||
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
|
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"outbox": "Outbox",
|
"outbox": "Outbox",
|
||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"agentMemory": "Agent Memory"
|
"agentMemory": "Agent Memory",
|
||||||
|
"graphRag": "Wissens-Graph"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -1854,6 +1855,34 @@
|
|||||||
"empty": "Keine Memories fuer diesen Agenten.",
|
"empty": "Keine Memories fuer diesen Agenten.",
|
||||||
"loadError": "Memories konnten nicht geladen werden."
|
"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": {
|
"policies": {
|
||||||
"title": "ABAC-Richtlinien",
|
"title": "ABAC-Richtlinien",
|
||||||
"create": "Richtlinie erstellen",
|
"create": "Richtlinie erstellen",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"outbox": "Outbox",
|
"outbox": "Outbox",
|
||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"agentMemory": "Agent Memory"
|
"agentMemory": "Agent Memory",
|
||||||
|
"graphRag": "Knowledge Graph"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
@@ -1854,6 +1855,34 @@
|
|||||||
"empty": "No memories for this agent.",
|
"empty": "No memories for this agent.",
|
||||||
"loadError": "Failed to load memories."
|
"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": {
|
"policies": {
|
||||||
"title": "ABAC Policies",
|
"title": "ABAC Policies",
|
||||||
"create": "Create policy",
|
"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