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:
@@ -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