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:
A-Dawn
2026-04-03 08:08:24 +08:00
parent bf5eb45709
commit 15d436b3a1
136 changed files with 52533 additions and 629 deletions

View File

@@ -0,0 +1,270 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { KnowledgeBasePage } from '../knowledge-base'
import * as memoryApi from '@/lib/memory-api'
const navigateMock = vi.fn()
const toastMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock('@/components', () => ({
CodeEditor: ({ value }: { value: string }) => <pre data-testid="code-editor">{value}</pre>,
MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
}))
vi.mock('@/components/memory/MemoryConfigEditor', () => ({
MemoryConfigEditor: () => <div data-testid="memory-config-editor">memory-config-editor</div>,
}))
vi.mock('@/components/memory/MemoryDeleteDialog', () => ({
MemoryDeleteDialog: ({
open,
preview,
}: {
open: boolean
preview?: { mode?: string; item_count?: number } | null
}) => (
open ? <div data-testid="memory-delete-dialog">{`delete:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div> : null
),
}))
vi.mock('@/lib/memory-api', () => ({
getMemoryConfigSchema: vi.fn(),
getMemoryConfig: vi.fn(),
getMemoryConfigRaw: vi.fn(),
getMemoryDeleteOperation: vi.fn(),
getMemoryRuntimeConfig: vi.fn(),
getMemoryImportGuide: vi.fn(),
getMemoryImportTasks: vi.fn(),
getMemoryTuningProfile: vi.fn(),
getMemoryTuningTasks: vi.fn(),
getMemorySources: vi.fn(),
getMemoryDeleteOperations: vi.fn(),
refreshMemoryRuntimeSelfCheck: vi.fn(),
updateMemoryConfig: vi.fn(),
updateMemoryConfigRaw: vi.fn(),
createMemoryPasteImport: vi.fn(),
createMemoryTuningTask: vi.fn(),
applyBestMemoryTuningProfile: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
}))
describe('KnowledgeBasePage', () => {
beforeEach(() => {
navigateMock.mockReset()
toastMock.mockReset()
vi.mocked(memoryApi.getMemoryConfigSchema).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
schema: {
plugin_id: 'a_memorix',
plugin_info: {
name: 'A_Memorix',
version: '2.0.0',
description: '长期记忆子系统',
author: 'A_Dawn',
},
_note: 'raw-only 字段仍可通过 TOML 编辑',
layout: {
type: 'tabs',
tabs: [{ id: 'basic', title: '基础', sections: ['plugin'], order: 1 }],
},
sections: {
plugin: {
name: 'plugin',
title: '子系统状态',
collapsed: false,
order: 1,
fields: {},
},
},
},
})
vi.mocked(memoryApi.getMemoryConfig).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
config: { plugin: { enabled: true } },
})
vi.mocked(memoryApi.getMemoryConfigRaw).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
config: '[plugin]\nenabled = true\n',
})
vi.mocked(memoryApi.getMemoryRuntimeConfig).mockResolvedValue({
success: true,
config: { plugin: { enabled: true } },
data_dir: 'data/plugins/a-dawn.a-memorix',
embedding_dimension: 1024,
auto_save: true,
relation_vectors_enabled: false,
runtime_ready: true,
embedding_degraded: false,
embedding_degraded_reason: '',
embedding_degraded_since: null,
embedding_last_check: null,
paragraph_vector_backfill_pending: 2,
paragraph_vector_backfill_running: 0,
paragraph_vector_backfill_failed: 1,
paragraph_vector_backfill_done: 3,
})
vi.mocked(memoryApi.getMemoryImportGuide).mockResolvedValue({
success: true,
content: '# 导入指南\n导入说明',
})
vi.mocked(memoryApi.getMemoryImportTasks).mockResolvedValue({
success: true,
items: [{ task_id: 'import-1', status: 'done', mode: 'text' }],
})
vi.mocked(memoryApi.getMemoryTuningProfile).mockResolvedValue({
success: true,
profile: { retrieval: { top_k: 10 } },
toml: '[retrieval]\ntop_k = 10\n',
})
vi.mocked(memoryApi.getMemoryTuningTasks).mockResolvedValue({
success: true,
items: [{ task_id: 'tune-1', status: 'done' }],
})
vi.mocked(memoryApi.getMemorySources).mockResolvedValue({
success: true,
items: [
{ source: 'demo-1', paragraph_count: 2, relation_count: 1 },
{ source: 'demo-2', paragraph_count: 1, relation_count: 0 },
],
count: 2,
})
vi.mocked(memoryApi.getMemoryDeleteOperations).mockResolvedValue({
success: true,
items: [
{
operation_id: 'del-1',
mode: 'source',
status: 'executed',
summary: { counts: { paragraphs: 2, relations: 1, sources: 1 } },
},
],
count: 1,
})
vi.mocked(memoryApi.getMemoryDeleteOperation).mockResolvedValue({
success: true,
operation: {
operation_id: 'del-1',
mode: 'source',
status: 'executed',
selector: { sources: ['demo-1'] },
summary: { counts: { paragraphs: 2, relations: 1, sources: 1 }, sources: ['demo-1'] },
items: [
{
item_type: 'paragraph',
item_hash: 'p-1',
item_key: 'paragraph:p-1',
payload: { paragraph: { source: 'demo-1', content: '这是用于测试删除详情展示的段落内容。' } },
},
],
},
})
vi.mocked(memoryApi.refreshMemoryRuntimeSelfCheck).mockResolvedValue({
success: true,
report: { ok: true },
})
vi.mocked(memoryApi.updateMemoryConfig).mockResolvedValue({
success: true,
config_path: 'config/a_memorix.toml',
} as never)
vi.mocked(memoryApi.updateMemoryConfigRaw).mockResolvedValue({
success: true,
config_path: 'config/a_memorix.toml',
} as never)
vi.mocked(memoryApi.createMemoryPasteImport).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.createMemoryTuningTask).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.applyBestMemoryTuningProfile).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
selector: { sources: ['demo-1'] },
counts: { sources: 1, paragraphs: 2, relations: 1 },
sources: ['demo-1'],
items: [{ item_type: 'paragraph', item_hash: 'p-1', label: 'demo-1' }],
item_count: 1,
dry_run: true,
} as never)
vi.mocked(memoryApi.executeMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
operation_id: 'del-2',
counts: { sources: 1, paragraphs: 2, relations: 1 },
sources: ['demo-1'],
deleted_count: 4,
deleted_entity_count: 0,
deleted_relation_count: 1,
deleted_paragraph_count: 2,
deleted_source_count: 1,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
})
it('renders long-term memory console and key tabs', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
expect(screen.getByText(/config\/a_memorix\.toml/)).toBeInTheDocument()
expect(screen.getByText('运行就绪')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '配置' }))
expect(await screen.findByTestId('memory-config-editor')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '导入' }))
expect(await screen.findByText(/导入说明/)).toBeInTheDocument()
expect(screen.getByText('import-1')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '调优' }))
expect(await screen.findByText('tune-1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '应用最佳' })).toBeInTheDocument()
})
it('shows delete tab and opens source delete preview', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '删除' }))
expect(await screen.findByText('来源批量删除')).toBeInTheDocument()
expect(screen.getAllByText('demo-1').length).toBeGreaterThan(0)
expect(screen.getAllByText('del-1').length).toBeGreaterThan(0)
expect(screen.getByText('恢复这次删除')).toBeInTheDocument()
await user.click(screen.getAllByRole('checkbox')[0])
await user.click(screen.getByRole('button', { name: '预览删除' }))
expect(await screen.findByTestId('memory-delete-dialog')).toHaveTextContent('delete:source:1')
})
it('loads selected delete operation detail items from detail endpoint', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '删除' }))
expect(await screen.findByText('删除操作恢复')).toBeInTheDocument()
expect(await screen.findByText('paragraph')).toBeInTheDocument()
expect(screen.getByText('p-1')).toBeInTheDocument()
expect(screen.getByText('这是用于测试删除详情展示的段落内容。')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,341 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { KnowledgeGraphPage } from '../knowledge-graph'
import * as memoryApi from '@/lib/memory-api'
const navigateMock = vi.fn()
const toastMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock('@/components/memory/MemoryDeleteDialog', () => ({
MemoryDeleteDialog: ({
open,
preview,
}: {
open: boolean
preview?: { mode?: string; item_count?: number } | null
}) => (
open ? <div data-testid="memory-delete-dialog">{`delete:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div> : null
),
}))
vi.mock('../knowledge-graph/GraphVisualization', () => ({
GraphVisualization: ({
graphData,
onNodeClick,
onEdgeClick,
}: {
graphData: { nodes: Array<{ id: string }>; edges: Array<{ source: string; target: string }> }
onNodeClick: (event: React.MouseEvent, node: { id: string }) => void
onEdgeClick: (event: React.MouseEvent, edge: { source: string; target: string }) => void
}) => (
<div data-testid="graph-visualization">
<div>{`nodes:${graphData.nodes.length},edges:${graphData.edges.length}`}</div>
{graphData.nodes[0] ? (
<button type="button" onClick={(event) => onNodeClick(event as never, { id: graphData.nodes[0].id })}>
</button>
) : null}
{graphData.edges[0] ? (
<button
type="button"
onClick={(event) =>
onEdgeClick(event as never, {
source: graphData.edges[0].source,
target: graphData.edges[0].target,
})}
>
</button>
) : null}
</div>
),
}))
vi.mock('../knowledge-graph/GraphDialogs', () => ({
NodeDetailDialog: ({
selectedNodeData,
nodeDetail,
onOpenEvidence,
onDeleteEntity,
}: {
selectedNodeData: { id: string } | null
nodeDetail: { relations?: Array<{ predicate: string }>; paragraphs?: Array<unknown> } | null
onOpenEvidence?: () => void
onDeleteEntity?: (options: { includeParagraphs: boolean }) => void
}) => (
selectedNodeData ? (
<div data-testid="node-detail-dialog">
<div>{`node:${selectedNodeData.id}`}</div>
<div>{`relations:${nodeDetail?.relations?.[0]?.predicate ?? 'none'}`}</div>
<div>{`paragraphs:${nodeDetail?.paragraphs?.length ?? 0}`}</div>
<button type="button" onClick={onOpenEvidence}></button>
<button type="button" onClick={() => onDeleteEntity?.({ includeParagraphs: true })}></button>
</div>
) : null
),
EdgeDetailDialog: ({
selectedEdgeData,
edgeDetail,
onOpenEvidence,
}: {
selectedEdgeData: { source: { id: string }; target: { id: string } } | null
edgeDetail: { edge?: { predicates?: string[] }; paragraphs?: Array<unknown> } | null
onOpenEvidence?: () => void
}) => (
selectedEdgeData ? (
<div data-testid="edge-detail-dialog">
<div>{`edge:${selectedEdgeData.source.id}->${selectedEdgeData.target.id}`}</div>
<div>{`predicates:${edgeDetail?.edge?.predicates?.join(',') ?? 'none'}`}</div>
<div>{`paragraphs:${edgeDetail?.paragraphs?.length ?? 0}`}</div>
<button type="button" onClick={onOpenEvidence}></button>
</div>
) : null
),
RelationDetailDialog: () => null,
ParagraphDetailDialog: () => null,
}))
vi.mock('@/lib/memory-api', () => ({
getMemoryGraph: vi.fn(),
getMemoryGraphNodeDetail: vi.fn(),
getMemoryGraphEdgeDetail: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
}))
describe('KnowledgeGraphPage', () => {
beforeEach(() => {
navigateMock.mockReset()
toastMock.mockReset()
vi.mocked(memoryApi.getMemoryGraph).mockResolvedValue({
success: true,
nodes: [
{ id: 'alpha', name: 'Alpha' },
{ id: 'beta', name: 'Beta' },
],
edges: [
{
source: 'alpha',
target: 'beta',
weight: 1,
predicates: ['关联'],
relation_count: 1,
evidence_count: 2,
relation_hashes: ['rel-1'],
label: '关联',
},
],
total_nodes: 2,
total_edges: 1,
})
vi.mocked(memoryApi.getMemoryGraphNodeDetail).mockResolvedValue({
success: true,
node: { id: 'alpha', type: 'entity', content: 'Alpha', hash: 'entity-1', appearance_count: 3 },
relations: [
{
hash: 'rel-1',
subject: 'alpha',
predicate: '关联',
object: 'beta',
text: 'alpha 关联 beta',
confidence: 0.9,
paragraph_count: 1,
paragraph_hashes: ['p-1'],
source_paragraph: 'p-1',
},
],
paragraphs: [
{
hash: 'p-1',
content: 'Alpha 提到了 Beta',
preview: 'Alpha 提到了 Beta',
source: 'demo',
entity_count: 2,
relation_count: 1,
entities: ['Alpha', 'Beta'],
relations: ['alpha 关联 beta'],
},
],
evidence_graph: {
nodes: [
{ id: 'entity:alpha', type: 'entity', content: 'Alpha' },
{ id: 'relation:rel-1', type: 'relation', content: 'alpha 关联 beta' },
{ id: 'paragraph:p-1', type: 'paragraph', content: 'Alpha 提到了 Beta' },
],
edges: [
{ source: 'paragraph:p-1', target: 'entity:alpha', kind: 'mentions', label: '提及', weight: 1 },
{ source: 'paragraph:p-1', target: 'relation:rel-1', kind: 'supports', label: '支撑', weight: 1 },
],
focus_entities: ['alpha'],
},
})
vi.mocked(memoryApi.getMemoryGraphEdgeDetail).mockResolvedValue({
success: true,
edge: {
source: 'alpha',
target: 'beta',
weight: 1,
predicates: ['关联'],
relation_count: 1,
evidence_count: 1,
relation_hashes: ['rel-1'],
label: '关联',
},
relations: [
{
hash: 'rel-1',
subject: 'alpha',
predicate: '关联',
object: 'beta',
text: 'alpha 关联 beta',
confidence: 0.9,
paragraph_count: 1,
paragraph_hashes: ['p-1'],
source_paragraph: 'p-1',
},
],
paragraphs: [
{
hash: 'p-1',
content: 'Alpha 提到了 Beta',
preview: 'Alpha 提到了 Beta',
source: 'demo',
entity_count: 2,
relation_count: 1,
entities: ['Alpha', 'Beta'],
relations: ['alpha 关联 beta'],
},
],
evidence_graph: {
nodes: [
{ id: 'entity:alpha', type: 'entity', content: 'Alpha' },
{ id: 'entity:beta', type: 'entity', content: 'Beta' },
{ id: 'relation:rel-1', type: 'relation', content: 'alpha 关联 beta' },
],
edges: [
{ source: 'relation:rel-1', target: 'entity:alpha', kind: 'subject', label: '主语', weight: 1 },
{ source: 'relation:rel-1', target: 'entity:beta', kind: 'object', label: '宾语', weight: 1 },
],
focus_entities: ['alpha', 'beta'],
},
})
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'mixed',
selector: { entity_hashes: ['entity-1'] },
counts: { entities: 1, relations: 1, paragraphs: 1 },
sources: ['demo'],
items: [{ item_type: 'entity', item_hash: 'entity-1', label: 'Alpha' }],
item_count: 1,
dry_run: true,
} as never)
vi.mocked(memoryApi.executeMemoryDelete).mockResolvedValue({
success: true,
mode: 'mixed',
operation_id: 'del-1',
counts: { entities: 1, relations: 1, paragraphs: 1 },
sources: ['demo'],
deleted_count: 3,
deleted_entity_count: 1,
deleted_relation_count: 1,
deleted_paragraph_count: 1,
deleted_source_count: 0,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
})
it('renders graph summary and supports empty-result filtering', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
expect(await screen.findByText('长期记忆图谱')).toBeInTheDocument()
expect(screen.getByText(/总节点 2/)).toBeInTheDocument()
expect(screen.getByTestId('graph-visualization')).toHaveTextContent('nodes:2,edges:1')
await user.type(screen.getByPlaceholderText('筛选实体名称、节点 ID 或边标签'), 'missing')
expect(memoryApi.getMemoryGraph).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '筛选' }))
expect(await screen.findByText('还没有可展示的长期记忆图谱')).toBeInTheDocument()
})
it('shows empty state when switching to evidence view without a selection', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
expect(await screen.findByTestId('graph-visualization')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '证据视图' }))
expect(await screen.findByText('证据视图还没有可展示的选择')).toBeInTheDocument()
})
it('closes node dialog when switching to evidence view and renders evidence graph', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择节点' }))
expect(await screen.findByTestId('node-detail-dialog')).toHaveTextContent('relations:关联')
expect(screen.getByTestId('node-detail-dialog')).toHaveTextContent('paragraphs:1')
await user.click(screen.getByRole('button', { name: '切到证据视图' }))
await waitFor(() => {
expect(screen.queryByTestId('node-detail-dialog')).not.toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('graph-visualization')).toHaveTextContent('nodes:3,edges:2')
})
})
it('loads edge detail with predicates and support paragraphs', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择边' }))
expect(await screen.findByTestId('edge-detail-dialog')).toHaveTextContent('predicates:关联')
expect(screen.getByTestId('edge-detail-dialog')).toHaveTextContent('paragraphs:1')
await user.click(screen.getByRole('button', { name: '切到证据视图' }))
await waitFor(() => {
expect(screen.queryByTestId('edge-detail-dialog')).not.toBeInTheDocument()
})
})
it('opens delete preview dialog from node detail', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择节点' }))
await screen.findByTestId('node-detail-dialog')
await user.click(screen.getByRole('button', { name: '删除实体' }))
await waitFor(() => {
expect(memoryApi.previewMemoryDelete).toHaveBeenCalled()
})
expect(await screen.findByTestId('memory-delete-dialog')).toHaveTextContent('delete:mixed:1')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -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 storeMB内存
</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>
)

View File

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

View File

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