refactor: 将 A_Memorix 重构为主线长期记忆子系统并重建管理界面
- 将 A_Memorix 从旧 submodule / 插件形态迁入主线源码,主体落到 src/A_memorix - 调整主程序接入方式,使 A_Memorix 作为源码内长期记忆子系统运行 - 回收父项目插件体系中针对 A_Memorix 的特判,减少对 plugin 通用层的侵入 - 将长期记忆配置、运行时、自检、导入、调优等能力收口到 memory 路由与主线服务层 - 重做长期记忆控制台与图谱页面,按 MaiBot 现有 dashboard 风格接入 - 补充实体关系图与证据视图双视图能力,支持查看节点、关系、段落及其证据链路 - 新增长期记忆配置编辑器与 memory-api,支持主线内配置管理 - 补齐删除管理能力:删除预览、混合删除、来源批量删除、删除操作恢复 - 优化删除预览与删除操作详情的前端展示,支持分页、检索,并以实体名/关系内容/段落摘要替代单纯 hash 展示 - 修复图谱与控制台相关前端问题,包括证据视图切换、查询触发时机、删除弹层空值保护等 - 新增或更新 A_Memorix 相关测试、WebUI 路由测试、前端 vitest 测试与辅助验证脚本 - 移除旧 plugins/A_memorix、.gitmodules 及相关历史维护文档
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -7,63 +12,204 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type {
|
||||
MemoryEvidenceParagraphNodeMetadata,
|
||||
MemoryEvidenceRelationNodeMetadata,
|
||||
MemoryGraphEdgeDetailPayload,
|
||||
MemoryGraphNodeDetailPayload,
|
||||
MemoryGraphParagraphDetailPayload,
|
||||
MemoryGraphRelationDetailPayload,
|
||||
} from '@/lib/memory-api'
|
||||
|
||||
import type { GraphNode, SelectedEdgeData } from './types'
|
||||
|
||||
function formatTimestamp(value?: number | null): string {
|
||||
if (!value) {
|
||||
return '未知'
|
||||
}
|
||||
const date = new Date(Number(value) * 1000)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '未知'
|
||||
}
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function RelationList({
|
||||
items,
|
||||
onDeleteRelation,
|
||||
}: {
|
||||
items: MemoryGraphRelationDetailPayload[]
|
||||
onDeleteRelation?: (relation: MemoryGraphRelationDetailPayload) => void
|
||||
}) {
|
||||
if (items.length <= 0) {
|
||||
return <p className="text-sm text-muted-foreground">暂无可展示的关系语义。</p>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((relation) => (
|
||||
<div key={relation.hash} className="rounded-lg border bg-muted/40 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{relation.predicate || '未命名谓词'}</Badge>
|
||||
<span className="text-xs text-muted-foreground">证据段落 {relation.paragraph_count}</span>
|
||||
<span className="text-xs text-muted-foreground">置信度 {relation.confidence.toFixed(3)}</span>
|
||||
</div>
|
||||
{onDeleteRelation ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onDeleteRelation(relation)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除关系
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-medium">{relation.text}</p>
|
||||
<code className="mt-2 block break-all text-xs text-muted-foreground">{relation.hash}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParagraphList({
|
||||
items,
|
||||
onDeleteParagraph,
|
||||
}: {
|
||||
items: MemoryGraphParagraphDetailPayload[]
|
||||
onDeleteParagraph?: (paragraph: MemoryGraphParagraphDetailPayload) => void
|
||||
}) {
|
||||
if (items.length <= 0) {
|
||||
return <p className="text-sm text-muted-foreground">暂无可展示的来源段落。</p>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((paragraph) => (
|
||||
<div key={paragraph.hash} className="rounded-lg border bg-muted/40 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{paragraph.source || '未命名来源'}</Badge>
|
||||
<span className="text-xs text-muted-foreground">实体 {paragraph.entity_count}</span>
|
||||
<span className="text-xs text-muted-foreground">关系 {paragraph.relation_count}</span>
|
||||
<span className="text-xs text-muted-foreground">更新时间 {formatTimestamp(paragraph.updated_at)}</span>
|
||||
</div>
|
||||
{onDeleteParagraph ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onDeleteParagraph(paragraph)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除段落
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm break-words">{paragraph.preview || paragraph.content}</p>
|
||||
{paragraph.entities.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{paragraph.entities.slice(0, 8).map((entity) => (
|
||||
<Badge key={`${paragraph.hash}-${entity}`} variant="outline" className="text-xs">
|
||||
{entity}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NodeDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedNodeData: GraphNode | null
|
||||
nodeDetail: MemoryGraphNodeDetailPayload | null
|
||||
loading?: boolean
|
||||
onOpenEvidence?: () => void
|
||||
onDeleteEntity?: (options: { includeParagraphs: boolean }) => void
|
||||
onDeleteRelation?: (relation: MemoryGraphRelationDetailPayload) => void
|
||||
onDeleteParagraph?: (paragraph: MemoryGraphParagraphDetailPayload) => void
|
||||
}
|
||||
|
||||
export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeDetailDialogProps) {
|
||||
export function NodeDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedNodeData,
|
||||
nodeDetail,
|
||||
loading = false,
|
||||
onOpenEvidence,
|
||||
onDeleteEntity,
|
||||
onDeleteRelation,
|
||||
onDeleteParagraph,
|
||||
}: NodeDetailDialogProps) {
|
||||
const node = nodeDetail?.node ?? selectedNodeData
|
||||
const [includeParagraphs, setIncludeParagraphs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIncludeParagraphs(false)
|
||||
}
|
||||
}, [open, node?.id])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>节点详情</DialogTitle>
|
||||
<DialogTitle>实体详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedNodeData && (
|
||||
<DialogBody className="h-full">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DialogBody className="h-full overflow-y-auto">
|
||||
{node ? (
|
||||
<div className="space-y-6 pb-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 rounded-xl border bg-muted/30 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">类型</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{node.type === 'entity' ? '实体' : node.type}</Badge>
|
||||
{'appearance_count' in (nodeDetail?.node ?? {}) && (
|
||||
<Badge variant="outline">出现次数 {nodeDetail?.node.appearance_count ?? 0}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-2 text-lg font-semibold">{node.content}</h3>
|
||||
<code className="mt-2 block break-all text-xs text-muted-foreground">{node.id}</code>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<Button variant="outline" onClick={onOpenEvidence} disabled={!onOpenEvidence}>
|
||||
切到证据视图
|
||||
</Button>
|
||||
{onDeleteEntity ? (
|
||||
<div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
删除该实体相关证据段落
|
||||
</label>
|
||||
<Button variant="outline" onClick={() => onDeleteEntity({ includeParagraphs })}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除实体
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">ID</p>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">正在加载节点证据…</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">相关关系</h4>
|
||||
<span className="text-xs text-muted-foreground">{nodeDetail?.relations.length ?? 0} 条</span>
|
||||
</div>
|
||||
<RelationList items={nodeDetail?.relations ?? []} onDeleteRelation={onDeleteRelation} />
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">内容</p>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
|
||||
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
💡 <strong>提示:</strong>段落内容显示不完整?
|
||||
<br />
|
||||
您可以在 <strong>配置 → WebUI 服务配置</strong> 中启用 "在知识图谱中加载段落完整内容" 选项,以显示段落的完整文本。
|
||||
<br />
|
||||
注意:此功能会额外再次加载 embedding store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">支持段落</h4>
|
||||
<span className="text-xs text-muted-foreground">{nodeDetail?.paragraphs.length ?? 0} 个</span>
|
||||
</div>
|
||||
<ParagraphList items={nodeDetail?.paragraphs ?? []} onDeleteParagraph={onDeleteParagraph} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">尚未选中实体。</p>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -73,49 +219,226 @@ interface EdgeDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedEdgeData: SelectedEdgeData | null
|
||||
edgeDetail: MemoryGraphEdgeDetailPayload | null
|
||||
loading?: boolean
|
||||
onOpenEvidence?: () => void
|
||||
onDeleteEdgeGroup?: (options: { includeParagraphs: boolean }) => void
|
||||
onDeleteRelation?: (relation: MemoryGraphRelationDetailPayload) => void
|
||||
onDeleteParagraph?: (paragraph: MemoryGraphParagraphDetailPayload) => void
|
||||
}
|
||||
|
||||
export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeDetailDialogProps) {
|
||||
export function EdgeDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedEdgeData,
|
||||
edgeDetail,
|
||||
loading = false,
|
||||
onOpenEvidence,
|
||||
onDeleteEdgeGroup,
|
||||
onDeleteRelation,
|
||||
onDeleteParagraph,
|
||||
}: EdgeDetailDialogProps) {
|
||||
const sourceLabel = selectedEdgeData?.source.content ?? edgeDetail?.edge.source ?? ''
|
||||
const targetLabel = selectedEdgeData?.target.content ?? edgeDetail?.edge.target ?? ''
|
||||
const [includeParagraphs, setIncludeParagraphs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIncludeParagraphs(false)
|
||||
}
|
||||
}, [open, edgeDetail?.edge.source, edgeDetail?.edge.target])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden grid grid-rows-[auto_1fr_auto]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>边详情</DialogTitle>
|
||||
<DialogTitle>关系详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEdgeData && (
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">源节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.source.id.slice(0, 40)}...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-muted-foreground flex-shrink-0">→</div>
|
||||
|
||||
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-muted-foreground mb-1">目标节点</div>
|
||||
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
|
||||
<code className="text-xs text-muted-foreground truncate block">
|
||||
{selectedEdgeData.target.id.slice(0, 40)}...
|
||||
</code>
|
||||
<DialogBody className="overflow-y-auto">
|
||||
{selectedEdgeData || edgeDetail ? (
|
||||
<div className="space-y-6 pb-2">
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(edgeDetail?.edge.predicates ?? []).map((predicate) => (
|
||||
<Badge key={predicate} variant="outline">{predicate}</Badge>
|
||||
))}
|
||||
<Badge variant="secondary">关系 {edgeDetail?.edge.relation_count ?? selectedEdgeData?.edge.relationCount ?? 0}</Badge>
|
||||
<Badge variant="secondary">证据 {edgeDetail?.edge.evidence_count ?? selectedEdgeData?.edge.evidenceCount ?? 0}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-base font-semibold break-words">
|
||||
{sourceLabel} → {targetLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
聚合权重 {(edgeDetail?.edge.weight ?? selectedEdgeData?.edge.weight ?? 0).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<Button variant="outline" onClick={onOpenEvidence} disabled={!onOpenEvidence}>
|
||||
切到证据视图
|
||||
</Button>
|
||||
{onDeleteEdgeGroup ? (
|
||||
<div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
同时删除支撑段落
|
||||
</label>
|
||||
<Button variant="outline" onClick={() => onDeleteEdgeGroup({ includeParagraphs })}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除此关系组
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">权重</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">正在加载边的证据…</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">关系语义</h4>
|
||||
<span className="text-xs text-muted-foreground">{edgeDetail?.relations.length ?? 0} 条</span>
|
||||
</div>
|
||||
<RelationList items={edgeDetail?.relations ?? []} onDeleteRelation={onDeleteRelation} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">支持段落</h4>
|
||||
<span className="text-xs text-muted-foreground">{edgeDetail?.paragraphs.length ?? 0} 个</span>
|
||||
</div>
|
||||
<ParagraphList items={edgeDetail?.paragraphs ?? []} onDeleteParagraph={onDeleteParagraph} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">尚未选中关系。</p>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface RelationDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
relation: MemoryGraphRelationDetailPayload | null
|
||||
metadata?: MemoryEvidenceRelationNodeMetadata | null
|
||||
onDeleteRelation?: (relation: MemoryGraphRelationDetailPayload, includeParagraphs: boolean) => void
|
||||
}
|
||||
|
||||
export function RelationDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
relation,
|
||||
metadata,
|
||||
onDeleteRelation,
|
||||
}: RelationDetailDialogProps) {
|
||||
const [includeParagraphs, setIncludeParagraphs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIncludeParagraphs(false)
|
||||
}
|
||||
}, [open, relation?.hash])
|
||||
|
||||
if (!relation) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>关系明细</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-4 overflow-y-auto">
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{relation.predicate || metadata?.predicate || '未命名谓词'}</Badge>
|
||||
<Badge variant="secondary">证据段落 {relation.paragraph_count}</Badge>
|
||||
<Badge variant="secondary">置信度 {relation.confidence.toFixed(3)}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-base font-semibold break-words">{relation.text}</p>
|
||||
<code className="mt-3 block break-all text-xs text-muted-foreground">{relation.hash}</code>
|
||||
</div>
|
||||
|
||||
{onDeleteRelation ? (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
同时删除支撑该关系的段落
|
||||
</label>
|
||||
<Button className="mt-3" variant="outline" onClick={() => onDeleteRelation(relation, includeParagraphs)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除这条关系
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface ParagraphDetailDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
paragraph: MemoryGraphParagraphDetailPayload | null
|
||||
metadata?: MemoryEvidenceParagraphNodeMetadata | null
|
||||
onDeleteParagraph?: (paragraph: MemoryGraphParagraphDetailPayload) => void
|
||||
}
|
||||
|
||||
export function ParagraphDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
paragraph,
|
||||
metadata,
|
||||
onDeleteParagraph,
|
||||
}: ParagraphDetailDialogProps) {
|
||||
if (!paragraph) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>段落明细</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-4 overflow-y-auto">
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{paragraph.source || metadata?.source || '未命名来源'}</Badge>
|
||||
<Badge variant="outline">实体 {paragraph.entity_count}</Badge>
|
||||
<Badge variant="outline">关系 {paragraph.relation_count}</Badge>
|
||||
<Badge variant="outline">更新时间 {formatTimestamp(paragraph.updated_at ?? metadata?.updated_at)}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm break-words">{paragraph.content}</p>
|
||||
<code className="mt-3 block break-all text-xs text-muted-foreground">{paragraph.hash}</code>
|
||||
</div>
|
||||
|
||||
{paragraph.entities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{paragraph.entities.map((entity) => (
|
||||
<Badge key={`${paragraph.hash}-${entity}`} variant="outline">{entity}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onDeleteParagraph ? (
|
||||
<Button variant="outline" onClick={() => onDeleteParagraph(paragraph)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除这段证据
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
@@ -7,8 +7,6 @@ import ReactFlow, {
|
||||
MiniMap,
|
||||
Panel,
|
||||
Position,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
@@ -47,8 +45,23 @@ const ParagraphNode = memo(({ data }: { data: { label: string; content: string }
|
||||
|
||||
ParagraphNode.displayName = 'ParagraphNode'
|
||||
|
||||
const RelationNode = memo(({ data }: { data: { label: string; content: string } }) => {
|
||||
return (
|
||||
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-amber-500 to-orange-600 border-2 border-orange-700 min-w-[140px]">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="font-medium text-white text-xs truncate max-w-[180px]" title={data.content}>
|
||||
{data.label}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RelationNode.displayName = 'RelationNode'
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
entity: EntityNode,
|
||||
relation: RelationNode,
|
||||
paragraph: ParagraphNode,
|
||||
}
|
||||
|
||||
@@ -61,7 +74,13 @@ function calculateLayout(nodes: GraphNode[], edges: GraphEdge[]): { nodes: FlowN
|
||||
const flowEdges: FlowEdge[] = []
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: 150, height: 50 })
|
||||
const size =
|
||||
node.type === 'relation'
|
||||
? { width: 180, height: 60 }
|
||||
: node.type === 'paragraph'
|
||||
? { width: 190, height: 56 }
|
||||
: { width: 150, height: 50 }
|
||||
dagreGraph.setNode(node.id, size)
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
@@ -82,22 +101,45 @@ function calculateLayout(nodes: GraphNode[], edges: GraphEdge[]): { nodes: FlowN
|
||||
data: {
|
||||
label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''),
|
||||
content: node.content,
|
||||
type: node.type,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
edges.forEach((edge, index) => {
|
||||
const isEvidenceEdge = edge.kind && edge.kind !== 'relation'
|
||||
const strokeColor =
|
||||
edge.kind === 'mentions'
|
||||
? '#0f766e'
|
||||
: edge.kind === 'supports'
|
||||
? '#b45309'
|
||||
: edge.kind === 'subject'
|
||||
? '#4f46e5'
|
||||
: edge.kind === 'object'
|
||||
? '#7c3aed'
|
||||
: '#64748b'
|
||||
const flowEdge: FlowEdge = {
|
||||
id: `edge-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
animated: nodes.length <= 200 && edge.weight > 5,
|
||||
animated: nodes.length <= 200 && (isEvidenceEdge || edge.weight > 5),
|
||||
style: {
|
||||
strokeWidth: Math.min(edge.weight / 2, 5),
|
||||
opacity: 0.6,
|
||||
strokeWidth: isEvidenceEdge ? Math.min(Math.max(edge.weight, 1.5), 4) : Math.min(edge.weight / 2, 5),
|
||||
opacity: isEvidenceEdge ? 0.9 : 0.6,
|
||||
stroke: strokeColor,
|
||||
},
|
||||
labelStyle: {
|
||||
fill: '#334155',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
},
|
||||
labelBgPadding: [6, 2],
|
||||
labelBgBorderRadius: 6,
|
||||
labelBgStyle: { fill: 'rgba(255,255,255,0.88)', fillOpacity: 0.95 },
|
||||
}
|
||||
if (edge.weight > 10 && nodes.length < 100) {
|
||||
if (edge.label && (isEvidenceEdge || nodes.length <= 120)) {
|
||||
flowEdge.label = edge.label
|
||||
} else if (edge.weight > 10 && nodes.length < 100) {
|
||||
flowEdge.label = `${edge.weight.toFixed(0)}`
|
||||
}
|
||||
flowEdges.push(flowEdge)
|
||||
@@ -114,13 +156,19 @@ interface GraphVisualizationProps {
|
||||
}
|
||||
|
||||
export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loading = false }: GraphVisualizationProps) {
|
||||
const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges)
|
||||
const [nodes, , onNodesChange] = useNodesState(flowNodes)
|
||||
const [edges, , onEdgesChange] = useEdgesState(flowEdges)
|
||||
const nodeCount = nodes.length
|
||||
const { nodes: flowNodes, edges: flowEdges } = useMemo(
|
||||
() => calculateLayout(graphData.nodes, graphData.edges),
|
||||
[graphData.edges, graphData.nodes],
|
||||
)
|
||||
const nodeCount = flowNodes.length
|
||||
const graphMode = useMemo(
|
||||
() => (graphData.nodes.some((node) => node.type !== 'entity') ? 'evidence' : 'entity'),
|
||||
[graphData.nodes],
|
||||
)
|
||||
|
||||
const miniMapNodeColor = useCallback((node: Node) => {
|
||||
if (node.type === 'entity') return '#6366f1'
|
||||
if (node.type === 'relation') return '#f59e0b'
|
||||
if (node.type === 'paragraph') return '#10b981'
|
||||
return '#6b7280'
|
||||
}, [])
|
||||
@@ -133,17 +181,15 @@ export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loadin
|
||||
<div
|
||||
style={{ touchAction: 'none' }}
|
||||
role="img"
|
||||
aria-label={`知识图谱可视化,共 ${nodeCount} 个节点,${edges.length} 条关系`}
|
||||
aria-label={`知识图谱可视化,共 ${nodeCount} 个节点,${flowEdges.length} 条关系`}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{`知识图谱包含 ${nodeCount} 个节点和 ${edges.length} 条关系。`}
|
||||
{`知识图谱包含 ${nodeCount} 个节点和 ${flowEdges.length} 条关系。`}
|
||||
</span>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -171,16 +217,34 @@ export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loadin
|
||||
)}
|
||||
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="text-sm font-semibold mb-2">
|
||||
{graphMode === 'entity' ? '实体关系图图例' : '证据视图图例'}
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" aria-hidden="true" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" aria-hidden="true" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{graphMode === 'evidence' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-amber-500 to-orange-600 border-2 border-orange-700" aria-hidden="true" />
|
||||
<span>关系节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" aria-hidden="true" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
紫色线表示关系到宾语,蓝色线表示关系到主语,绿色/橙色线表示段落证据。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{graphMode === 'entity' && (
|
||||
<div className="text-muted-foreground">
|
||||
线条表示实体间聚合关系,边标签优先显示主谓词,更多语义可点击查看详情。
|
||||
</div>
|
||||
)}
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,27 @@ import type { Node, Edge } from 'reactflow'
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: 'entity' | 'paragraph'
|
||||
type: 'entity' | 'relation' | 'paragraph'
|
||||
content: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
weight: number
|
||||
kind?: 'relation' | 'mentions' | 'supports' | 'subject' | 'object'
|
||||
label?: string
|
||||
relationHashes?: string[]
|
||||
predicates?: string[]
|
||||
relationCount?: number
|
||||
evidenceCount?: number
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
focusEntities?: string[]
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
@@ -27,6 +35,7 @@ export interface GraphStats {
|
||||
export interface FlowNodeData {
|
||||
label: string
|
||||
content: string
|
||||
type: GraphNode['type']
|
||||
}
|
||||
|
||||
export type FlowNode = Node<FlowNodeData>
|
||||
|
||||
Reference in New Issue
Block a user