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

This commit is contained in:
Agent Zero
2026-09-15 08:25:21 +02:00
parent 4eba9eb1d9
commit 0404c8f5dc
8 changed files with 876 additions and 5 deletions
+476
View File
@@ -0,0 +1,476 @@
/**
* GraphRAG page — knowledge graph traversal and relationship management
* (UI-Backlog module 11/16).
*
* Backend: /api/v1/graph (graph_rag plugin)
* - POST /traverse → BFS traversal (max_hops 1-10, type filter)
* - GET /relationships → paginated list with filters
* - POST /relationships → create relationship (409 on duplicate)
* - DELETE /relationships/{id} → delete relationship
* Permissions: graph:read (traverse, list) / graph:write (create, delete).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Share2,
Plus,
Trash2,
Inbox,
AlertTriangle,
Search,
ChevronRight,
} from 'lucide-react';
import {
fetchGraphRelationships,
createGraphRelationship,
deleteGraphRelationship,
traverseGraph,
type GraphRelationship,
type GraphTraverseResult,
type GraphNode,
} from '@/api/knowledge';
import { usePermission } from '@/hooks/usePermission';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
const PAGE_SIZE = 20;
const ENTITY_TYPE_SUGGESTIONS = [
'contact', 'company', 'file', 'task', 'calendar_event',
'mail_message', 'workflow', 'wiki_article', 'deal',
];
const DEPTH_BORDER: Record<number, string> = {
0: 'border-primary-300 dark:border-primary-700',
1: 'border-secondary-300 dark:border-secondary-600',
2: 'border-secondary-200 dark:border-secondary-700',
};
function nodeKey(n: GraphNode): string {
return `${n.entity_type}:${n.entity_id}`;
}
export function GraphRagPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { hasPermission } = usePermission();
const canRead = hasPermission('graph:read');
const canWrite = hasPermission('graph:write');
// ── Traverse state ──
const [srcType, setSrcType] = useState('contact');
const [srcId, setSrcId] = useState('');
const [maxHops, setMaxHops] = useState('3');
const [relTypeFilter, setRelTypeFilter] = useState('');
const [traverseResult, setTraverseResult] = useState<GraphTraverseResult | null>(null);
const [traverseError, setTraverseError] = useState<string | null>(null);
// ── Relationships state ──
const [page, setPage] = useState(1);
const [listTypeFilter, setListTypeFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
source_type: 'contact',
source_id: '',
target_type: 'file',
target_id: '',
relationship_type: '',
});
const listQuery = useQuery({
queryKey: ['graphRelationships', page, listTypeFilter],
queryFn: () =>
fetchGraphRelationships({
page,
page_size: PAGE_SIZE,
...(listTypeFilter ? { relationship_type: listTypeFilter } : {}),
}),
enabled: canRead,
});
const createMut = useMutation({
mutationFn: createGraphRelationship,
onSuccess: () => {
setShowCreate(false);
qc.invalidateQueries({ queryKey: ['graphRelationships'] });
},
});
const deleteMut = useMutation({
mutationFn: deleteGraphRelationship,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['graphRelationships'] });
},
});
const traverseMut = useMutation({
mutationFn: traverseGraph,
onSuccess: (result) => {
setTraverseResult(result);
setTraverseError(null);
},
onError: (err) => {
setTraverseError(err instanceof Error ? err.message : 'Traversal failed');
},
});
const runTraverse = () => {
if (!srcId.trim() || !srcType.trim()) return;
const types = relTypeFilter
.split(',')
.map((s) => s.trim())
.filter(Boolean);
traverseMut.mutate({
source_type: srcType.trim(),
source_id: srcId.trim(),
max_hops: Math.max(1, Math.min(10, parseInt(maxHops, 10) || 3)),
relationship_types: types.length > 0 ? types : null,
});
};
const submitCreate = () => {
if (!form.source_id.trim() || !form.target_id.trim() || !form.relationship_type.trim()) return;
createMut.mutate({
source_type: form.source_type.trim(),
source_id: form.source_id.trim(),
target_type: form.target_type.trim(),
target_id: form.target_id.trim(),
relationship_type: form.relationship_type.trim(),
});
};
const handleDelete = (rel: GraphRelationship) => {
if (window.confirm(t('graphRag.deleteConfirm', { type: rel.relationship_type }))) {
deleteMut.mutate(rel.id);
}
};
// Group traverse nodes by depth for display
const nodesByDepth = React.useMemo(() => {
if (!traverseResult) return [];
const map = new Map<number, GraphNode[]>();
for (const n of traverseResult.nodes) {
const arr = map.get(n.depth) ?? [];
arr.push(n);
map.set(n.depth, arr);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [traverseResult]);
const items = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const formValid =
form.source_type.trim() &&
form.source_id.trim() &&
form.target_type.trim() &&
form.target_id.trim() &&
form.relationship_type.trim();
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6" data-testid="graph-rag-page">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Share2 className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('graphRag.title')}
</h1>
</div>
{canWrite && (
<Button onClick={() => setShowCreate(true)} data-testid="graph-create-btn">
<Plus className="w-4 h-4" aria-hidden="true" />
{t('graphRag.create')}
</Button>
)}
</div>
{/* ── Traversal panel ── */}
<Card className="p-4 space-y-4" data-testid="graph-traverse-panel">
<h2 className="text-base font-semibold text-secondary-900 dark:text-secondary-100">
{t('graphRag.traverseTitle')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
<div>
<Input
label={t('graphRag.sourceType')}
value={srcType}
onChange={(e) => setSrcType(e.target.value)}
list="graph-entity-types"
maxLength={50}
data-testid="graph-traverse-source-type"
/>
</div>
<div>
<Input
label={t('graphRag.sourceId')}
value={srcId}
onChange={(e) => setSrcId(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-traverse-source-id"
/>
</div>
<div>
<Input
label={t('graphRag.maxHops')}
type="number"
min={1}
max={10}
value={maxHops}
onChange={(e) => setMaxHops(e.target.value)}
data-testid="graph-traverse-max-hops"
/>
</div>
<div>
<Input
label={t('graphRag.typeFilter')}
value={relTypeFilter}
onChange={(e) => setRelTypeFilter(e.target.value)}
placeholder="works_for, has_email"
data-testid="graph-traverse-type-filter"
/>
</div>
</div>
<datalist id="graph-entity-types">
{ENTITY_TYPE_SUGGESTIONS.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<div className="flex items-center gap-3">
<Button
onClick={runTraverse}
disabled={!canRead || !srcId.trim() || traverseMut.isPending}
data-testid="graph-traverse-submit"
>
<Search className="w-4 h-4" aria-hidden="true" />
{traverseMut.isPending ? t('graphRag.traversing') : t('graphRag.traverse')}
</Button>
{traverseResult && (
<span className="text-xs text-secondary-500" data-testid="graph-traverse-stats">
{t('graphRag.stats', {
nodes: traverseResult.total_nodes,
edges: traverseResult.total_edges,
})}
</span>
)}
</div>
{traverseError && (
<p className="text-sm text-danger-600 dark:text-danger-400" data-testid="graph-traverse-error">
{traverseError}
</p>
)}
{/* Traversal results grouped by depth */}
{nodesByDepth.length > 0 && (
<div className="space-y-3 pt-2 border-t border-secondary-200 dark:border-secondary-700" data-testid="graph-traverse-results">
{nodesByDepth.map(([depth, nodes]) => (
<div key={depth}>
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-1">
{t('graphRag.depth', { depth })} ({nodes.length})
</h3>
<div className="flex flex-wrap gap-2">
{nodes.map((n) => (
<span
key={nodeKey(n)}
className={clsx(
'inline-flex items-center gap-1 px-2 py-1 rounded-md border text-xs font-mono bg-white dark:bg-secondary-800',
DEPTH_BORDER[Math.min(depth, 2)] ?? DEPTH_BORDER[2],
)}
title={n.path.join(' → ')}
data-testid={`graph-node-${n.entity_type}-${n.entity_id.slice(0, 8)}`}
>
<span className="font-semibold">{n.entity_type}</span>
<span className="text-secondary-400">{n.entity_id.slice(0, 8)}</span>
</span>
))}
</div>
</div>
))}
{traverseResult && traverseResult.edges.length > 0 && (
<details className="text-xs text-secondary-600 dark:text-secondary-400">
<summary className="cursor-pointer font-medium">
{t('graphRag.edgesCount', { count: traverseResult.edges.length })}
</summary>
<ul className="mt-2 space-y-1 font-mono">
{traverseResult.edges.slice(0, 50).map((e, i) => (
<li key={i} className="break-all">
{e.source_type}:{e.source_id.slice(0, 8)}
<ChevronRight className="inline w-3 h-3 mx-0.5" aria-hidden="true" />
<span className="font-semibold">{e.relationship_type}</span>
<ChevronRight className="inline w-3 h-3 mx-0.5" aria-hidden="true" />
{e.target_type}:{e.target_id.slice(0, 8)}
</li>
))}
</ul>
</details>
)}
</div>
)}
</Card>
{/* ── Relationships list ── */}
<section aria-labelledby="graph-rel-heading">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-2">
<h2
id="graph-rel-heading"
className="text-base font-semibold text-secondary-900 dark:text-secondary-100"
>
{t('graphRag.relationshipsTitle')}
{total > 0 && <span className="ml-2 text-xs font-normal text-secondary-500">({total})</span>}
</h2>
<div className="w-48">
<Input
value={listTypeFilter}
onChange={(e) => {
setListTypeFilter(e.target.value);
setPage(1);
}}
placeholder={t('graphRag.typeFilter')}
aria-label={t('graphRag.typeFilter')}
data-testid="graph-list-type-filter"
/>
</div>
</div>
{!canRead ? (
<Card className="p-8 text-center" data-testid="graph-no-permission">
<AlertTriangle className="w-8 h-8 mx-auto text-warning-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('graphRag.noPermission')}</p>
</Card>
) : listQuery.isLoading ? (
<div className="flex items-center justify-center py-12" role="status">
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
</div>
) : listQuery.isError ? (
<Card className="p-8 text-center">
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('graphRag.loadError')}</p>
</Card>
) : items.length === 0 ? (
<Card className="p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
<p className="mt-3 text-sm text-secondary-500" data-testid="graph-empty">
{t('graphRag.empty')}
</p>
</Card>
) : (
<div className="space-y-2">
{items.map((rel) => (
<Card key={rel.id} className="p-3" data-testid={`graph-rel-${rel.id}`}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 text-xs font-mono break-all">
<span className="font-semibold">{rel.source_type}</span>
<span className="text-secondary-400">:{rel.source_id.slice(0, 8)}</span>
<ChevronRight className="inline w-3 h-3 mx-1 text-primary-500" aria-hidden="true" />
<span className="font-semibold text-primary-600 dark:text-primary-400">
{rel.relationship_type}
</span>
<ChevronRight className="inline w-3 h-3 mx-1 text-primary-500" aria-hidden="true" />
<span className="font-semibold">{rel.target_type}</span>
<span className="text-secondary-400">:{rel.target_id.slice(0, 8)}</span>
</div>
{canWrite && (
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(rel)}
disabled={deleteMut.isPending}
aria-label={t('graphRag.delete')}
data-testid={`graph-rel-delete-${rel.id}`}
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-center gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
{t('graphRag.prev')}
</Button>
<span className="text-sm text-secondary-500 self-center">{page} / {totalPages}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
{t('graphRag.next')}
</Button>
</div>
)}
</div>
)}
</section>
{/* ── Create modal ── */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={t('graphRag.createTitle')} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label={t('graphRag.sourceType')}
value={form.source_type}
onChange={(e) => setForm((f) => ({ ...f, source_type: e.target.value }))}
list="graph-entity-types"
maxLength={50}
data-testid="graph-form-source-type"
/>
<Input
label={t('graphRag.sourceId')}
value={form.source_id}
onChange={(e) => setForm((f) => ({ ...f, source_id: e.target.value }))}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-form-source-id"
/>
<Input
label={t('graphRag.targetType')}
value={form.target_type}
onChange={(e) => setForm((f) => ({ ...f, target_type: e.target.value }))}
list="graph-entity-types"
maxLength={50}
data-testid="graph-form-target-type"
/>
<Input
label={t('graphRag.targetId')}
value={form.target_id}
onChange={(e) => setForm((f) => ({ ...f, target_id: e.target.value }))}
placeholder="00000000-0000-0000-0000-000000000000"
data-testid="graph-form-target-id"
/>
</div>
<Input
label={t('graphRag.relationshipType')}
value={form.relationship_type}
onChange={(e) => setForm((f) => ({ ...f, relationship_type: e.target.value }))}
placeholder="works_for, has_email, relates_to"
maxLength={50}
data-testid="graph-form-rel-type"
/>
{createMut.isError && (
<p className="text-sm text-danger-600 dark:text-danger-400" data-testid="graph-form-error">
{createMut.error instanceof Error ? createMut.error.message : t('graphRag.createError')}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setShowCreate(false)}>
{t('common.cancel')}
</Button>
<Button onClick={submitCreate} disabled={!formValid || createMut.isPending} data-testid="graph-form-submit">
{t('graphRag.createSubmit')}
</Button>
</div>
</div>
</Modal>
</div>
);
}