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:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { linter } from '@codemirror/lint'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
@@ -29,7 +30,7 @@ interface CodeEditorProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languageExtensions: Record<Language, any[]> = {
|
||||
python: [python()],
|
||||
json: [json(), jsonParseLinter()],
|
||||
json: [json(), linter(jsonParseLinter())],
|
||||
toml: [StreamLanguage.define(tomlMode)],
|
||||
css: [css()],
|
||||
text: [],
|
||||
|
||||
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ListFieldEditor } from '@/components'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api'
|
||||
|
||||
interface MemoryConfigEditorProps {
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
let current: unknown = config
|
||||
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current as Record<string, unknown>
|
||||
}
|
||||
|
||||
function setNestedField(
|
||||
config: Record<string, unknown>,
|
||||
path: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
const nextConfig: Record<string, unknown> = { ...config }
|
||||
let target = nextConfig
|
||||
let source: Record<string, unknown> | undefined = config
|
||||
|
||||
for (const part of parts) {
|
||||
const sourceValue: unknown = source?.[part]
|
||||
const nextValue =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? { ...(sourceValue as Record<string, unknown>) }
|
||||
: {}
|
||||
target[part] = nextValue
|
||||
target = nextValue
|
||||
source =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? (sourceValue as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
target[fieldName] = value
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
field: ConfigFieldSchema
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [jsonDraft, setJsonDraft] = useState(
|
||||
typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2),
|
||||
)
|
||||
|
||||
switch (field.ui_type) {
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{field.label}</Label>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(value ?? field.default)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Select
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.choices ?? []).map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={field.rows ?? 4}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<ListFieldEditor
|
||||
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
|
||||
onChange={onChange as (value: unknown[]) => void}
|
||||
itemType={field.item_type}
|
||||
itemFields={field.item_fields}
|
||||
minItems={field.min_items}
|
||||
maxItems={field.max_items}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={jsonDraft}
|
||||
rows={field.rows ?? 6}
|
||||
disabled={disabled || field.disabled}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setJsonDraft(nextValue)
|
||||
try {
|
||||
onChange(JSON.parse(nextValue))
|
||||
} catch {
|
||||
// keep draft until valid JSON
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
sectionName,
|
||||
schema,
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
sectionName: string
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const section = schema.sections[sectionName]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionValues = getNestedRecord(config, sectionName) ?? {}
|
||||
const orderedFields = Object.values(section.fields).sort((left, right) => left.order - right.order)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
{section.description && <CardDescription>{section.description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{orderedFields.map((field) => (
|
||||
<FieldRenderer
|
||||
key={`${sectionName}.${field.name}`}
|
||||
field={field}
|
||||
value={sectionValues[field.name]}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange(setNestedField(config, sectionName, field.name, value))}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryConfigEditor({ schema, config, onChange, disabled }: MemoryConfigEditorProps) {
|
||||
const tabs = useMemo(
|
||||
() => [...(schema.layout.tabs ?? [])].sort((left, right) => left.order - right.order),
|
||||
[schema.layout.tabs],
|
||||
)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
const orderedSections = Object.keys(schema.sections).sort(
|
||||
(left, right) => (schema.sections[left]?.order ?? 0) - (schema.sections[right]?.order ?? 0),
|
||||
)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{orderedSections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={tabs[0]?.id} className="space-y-4">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||
{tab.sections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type {
|
||||
MemoryDeleteExecutePayload,
|
||||
MemoryDeletePreviewItemPayload,
|
||||
MemoryDeletePreviewPayload,
|
||||
} from '@/lib/memory-api'
|
||||
|
||||
const DELETE_PREVIEW_PAGE_SIZE = 8
|
||||
|
||||
function formatMode(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'entity':
|
||||
return '实体删除'
|
||||
case 'relation':
|
||||
return '关系删除'
|
||||
case 'paragraph':
|
||||
return '段落删除'
|
||||
case 'source':
|
||||
return '来源删除'
|
||||
case 'mixed':
|
||||
return '混合删除'
|
||||
default:
|
||||
return mode || '删除'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountLabel(label: string, value: number): string {
|
||||
return `${label} ${value}`
|
||||
}
|
||||
|
||||
function PreviewItemList({ items }: { items: MemoryDeletePreviewItemPayload[] }) {
|
||||
if (items.length <= 0) {
|
||||
return <p className="text-sm text-muted-foreground">当前预览没有可展示的明细项。</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 16).map((item) => (
|
||||
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{item.item_type}</Badge>
|
||||
{item.source ? <Badge variant="secondary">{item.source}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium break-words">{item.label || item.item_key || item.item_hash}</div>
|
||||
{item.preview ? <div className="mt-1 text-xs text-muted-foreground break-words">{item.preview}</div> : null}
|
||||
<code className="mt-2 block break-all text-[11px] text-muted-foreground">{item.item_hash || item.item_key}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoryDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
preview: MemoryDeletePreviewPayload | null
|
||||
result: MemoryDeleteExecutePayload | null
|
||||
loadingPreview?: boolean
|
||||
executing?: boolean
|
||||
restoring?: boolean
|
||||
error?: string | null
|
||||
onExecute: () => void
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function MemoryDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
preview,
|
||||
result,
|
||||
loadingPreview = false,
|
||||
executing = false,
|
||||
restoring = false,
|
||||
error,
|
||||
onExecute,
|
||||
onRestore,
|
||||
}: MemoryDeleteDialogProps) {
|
||||
const [itemSearch, setItemSearch] = useState('')
|
||||
const [itemPage, setItemPage] = useState(1)
|
||||
const counts = preview?.counts ?? result?.counts ?? {}
|
||||
const previewSources = Array.isArray(preview?.sources) ? preview.sources : []
|
||||
const previewItems = Array.isArray(preview?.items) ? preview.items : []
|
||||
const filteredPreviewItems = useMemo(() => {
|
||||
const keyword = itemSearch.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return previewItems
|
||||
}
|
||||
return previewItems.filter((item) =>
|
||||
[
|
||||
item.item_type,
|
||||
item.item_hash,
|
||||
item.item_key,
|
||||
item.label,
|
||||
item.preview,
|
||||
item.source,
|
||||
]
|
||||
.map((value) => String(value ?? '').toLowerCase())
|
||||
.some((value) => value.includes(keyword)),
|
||||
)
|
||||
}, [itemSearch, previewItems])
|
||||
const itemPageCount = Math.max(1, Math.ceil(filteredPreviewItems.length / DELETE_PREVIEW_PAGE_SIZE))
|
||||
const pagedPreviewItems = useMemo(() => {
|
||||
const start = (itemPage - 1) * DELETE_PREVIEW_PAGE_SIZE
|
||||
return filteredPreviewItems.slice(start, start + DELETE_PREVIEW_PAGE_SIZE)
|
||||
}, [filteredPreviewItems, itemPage])
|
||||
const countBadges = [
|
||||
{ key: 'entities', label: '实体', value: Number(counts.entities ?? 0) },
|
||||
{ key: 'relations', label: '关系', value: Number(counts.relations ?? 0) },
|
||||
{ key: 'paragraphs', label: '段落', value: Number(counts.paragraphs ?? 0) },
|
||||
{ key: 'sources', label: '来源', value: Number(counts.sources ?? 0) },
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
useEffect(() => {
|
||||
setItemSearch('')
|
||||
setItemPage(1)
|
||||
}, [preview?.mode, preview?.item_count, open])
|
||||
|
||||
useEffect(() => {
|
||||
setItemPage(1)
|
||||
}, [itemSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (itemPage > itemPageCount) {
|
||||
setItemPage(itemPageCount)
|
||||
}
|
||||
}, [itemPage, itemPageCount])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] grid grid-rows-[auto_1fr_auto]" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 overflow-y-auto">
|
||||
{loadingPreview ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">正在生成删除预览...</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{formatMode(preview.mode)}</Badge>
|
||||
<Badge variant="secondary">{formatCountLabel('预览项', Number(preview.item_count ?? previewItems.length))}</Badge>
|
||||
{countBadges.map((item) => (
|
||||
<Badge key={item.key} variant="outline">
|
||||
{formatCountLabel(item.label, item.value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{previewSources.length > 0 ? (
|
||||
<div className="mt-3 text-sm text-muted-foreground break-words">
|
||||
关联来源:{previewSources.join('、')}
|
||||
</div>
|
||||
) : null}
|
||||
{preview.matched_source_count ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
命中来源 {preview.matched_source_count}
|
||||
{preview.requested_source_count ? ` / 请求来源 ${preview.requested_source_count}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">本次将删除的对象摘要</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
命中 {filteredPreviewItems.length} / {previewItems.length} 项
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:min-w-[300px]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={itemSearch}
|
||||
onChange={(event) => setItemSearch(event.target.value)}
|
||||
placeholder="搜索类型 / hash / item_key / source"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>第 {itemPage} / {itemPageCount} 页</span>
|
||||
<span>每页 {DELETE_PREVIEW_PAGE_SIZE} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[320px] rounded-lg border bg-background/60">
|
||||
<div className="p-3">
|
||||
<PreviewItemList items={pagedPreviewItems} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.max(1, current - 1))}
|
||||
disabled={itemPage <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
支持按对象类型、hash、item_key、source 和预览内容检索
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.min(itemPageCount, current + 1))}
|
||||
disabled={itemPage >= itemPageCount}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{result?.success ? (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-1">
|
||||
<div>删除执行成功,操作 ID:<code>{result.operation_id}</code></div>
|
||||
<div>
|
||||
实际删除:实体 {result.deleted_entity_count},关系 {result.deleted_relation_count},段落 {result.deleted_paragraph_count},来源 {result.deleted_source_count}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{result?.success && onRestore ? (
|
||||
<Button variant="outline" onClick={onRestore} disabled={restoring}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{restoring ? '恢复中...' : '恢复本次删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!result?.success ? (
|
||||
<Button data-dialog-action="confirm" variant="destructive" onClick={onExecute} disabled={loadingPreview || executing || !preview}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{executing ? '执行中...' : '确认删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -32,8 +32,8 @@
|
||||
"expressionManagement": "Expression Management",
|
||||
"slangManagement": "Slang Management",
|
||||
"personInfo": "Person Info",
|
||||
"knowledgeGraph": "Knowledge Graph",
|
||||
"knowledgeBase": "Knowledge Base",
|
||||
"knowledgeGraph": "Long-Term Memory Graph",
|
||||
"knowledgeBase": "Long-Term Memory Console",
|
||||
"pluginMarket": "Plugin Market",
|
||||
"configTemplate": "Config Templates",
|
||||
"pluginConfig": "Plugin Config",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"expressionManagement": "表現管理",
|
||||
"slangManagement": "スラング管理",
|
||||
"personInfo": "人物情報",
|
||||
"knowledgeGraph": "知識グラフ",
|
||||
"knowledgeBase": "ナレッジベース",
|
||||
"knowledgeGraph": "長期記憶グラフ",
|
||||
"knowledgeBase": "長期記憶コンソール",
|
||||
"pluginMarket": "プラグインマーケット",
|
||||
"configTemplate": "設定テンプレート",
|
||||
"pluginConfig": "プラグイン設定",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"expressionManagement": "표현 관리",
|
||||
"slangManagement": "슬랭 관리",
|
||||
"personInfo": "인물 정보",
|
||||
"knowledgeGraph": "지식 그래프",
|
||||
"knowledgeBase": "지식 베이스",
|
||||
"knowledgeGraph": "장기 기억 그래프",
|
||||
"knowledgeBase": "장기 기억 콘솔",
|
||||
"pluginMarket": "플러그인 마켓",
|
||||
"configTemplate": "설정 템플릿",
|
||||
"pluginConfig": "플러그인 설정",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"expressionManagement": "表达方式管理",
|
||||
"slangManagement": "黑话管理",
|
||||
"personInfo": "人物信息管理",
|
||||
"knowledgeGraph": "知识库图谱可视化",
|
||||
"knowledgeBase": "麦麦知识库管理",
|
||||
"knowledgeGraph": "长期记忆图谱",
|
||||
"knowledgeBase": "长期记忆控制台",
|
||||
"pluginMarket": "插件市场",
|
||||
"configTemplate": "配置模板市场",
|
||||
"pluginConfig": "插件配置",
|
||||
|
||||
509
dashboard/src/lib/memory-api.ts
Normal file
509
dashboard/src/lib/memory-api.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import type { PluginConfigSchema } from '@/lib/plugin-api'
|
||||
|
||||
import { getApiBaseUrl } from './api-base'
|
||||
import { isElectron } from './runtime'
|
||||
|
||||
async function getMemoryApiBase(): Promise<string> {
|
||||
if (isElectron()) {
|
||||
const base = await getApiBaseUrl()
|
||||
return base ? `${base}/api/webui/memory` : '/api/webui/memory'
|
||||
}
|
||||
return import.meta.env.VITE_API_BASE_URL
|
||||
? `${import.meta.env.VITE_API_BASE_URL}/memory`
|
||||
: '/api/webui/memory'
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${await getMemoryApiBase()}${path}`, init)
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status}`
|
||||
try {
|
||||
const payload = await response.json()
|
||||
detail = String(payload?.detail ?? payload?.error ?? detail)
|
||||
} catch {
|
||||
// ignore json parsing fallback
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export interface MemoryGraphNodePayload {
|
||||
id: string
|
||||
name: string
|
||||
attributes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MemoryGraphEdgePayload {
|
||||
source: string
|
||||
target: string
|
||||
weight: number
|
||||
relation_hashes?: string[]
|
||||
predicates?: string[]
|
||||
relation_count?: number
|
||||
evidence_count?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface MemoryGraphPayload {
|
||||
success: boolean
|
||||
nodes: MemoryGraphNodePayload[]
|
||||
edges: MemoryGraphEdgePayload[]
|
||||
total_nodes: number
|
||||
total_edges: number
|
||||
}
|
||||
|
||||
export interface MemoryGraphRelationDetailPayload {
|
||||
hash: string
|
||||
subject: string
|
||||
predicate: string
|
||||
object: string
|
||||
text: string
|
||||
confidence: number
|
||||
paragraph_count: number
|
||||
paragraph_hashes: string[]
|
||||
source_paragraph: string
|
||||
}
|
||||
|
||||
export interface MemoryGraphParagraphDetailPayload {
|
||||
hash: string
|
||||
content: string
|
||||
preview: string
|
||||
source: string
|
||||
created_at?: number | null
|
||||
updated_at?: number | null
|
||||
entity_count: number
|
||||
relation_count: number
|
||||
entities: string[]
|
||||
relations: string[]
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceGraphNodePayload {
|
||||
id: string
|
||||
type: 'entity' | 'relation' | 'paragraph'
|
||||
content: string
|
||||
metadata?: MemoryEvidenceGraphNodeMetadata
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceGraphEdgePayload {
|
||||
source: string
|
||||
target: string
|
||||
kind: 'mentions' | 'supports' | 'subject' | 'object'
|
||||
label: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceGraphPayload {
|
||||
nodes: MemoryEvidenceGraphNodePayload[]
|
||||
edges: MemoryEvidenceGraphEdgePayload[]
|
||||
focus_entities: string[]
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceEntityNodeMetadata extends Record<string, unknown> {
|
||||
entity_name?: string
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceRelationNodeMetadata extends Record<string, unknown> {
|
||||
hash?: string
|
||||
subject?: string
|
||||
predicate?: string
|
||||
object?: string
|
||||
confidence?: number
|
||||
paragraph_count?: number
|
||||
paragraph_hashes?: string[]
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface MemoryEvidenceParagraphNodeMetadata extends Record<string, unknown> {
|
||||
hash?: string
|
||||
source?: string
|
||||
updated_at?: number | null
|
||||
entity_count?: number
|
||||
relation_count?: number
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type MemoryEvidenceGraphNodeMetadata =
|
||||
| MemoryEvidenceEntityNodeMetadata
|
||||
| MemoryEvidenceRelationNodeMetadata
|
||||
| MemoryEvidenceParagraphNodeMetadata
|
||||
| Record<string, unknown>
|
||||
|
||||
export interface MemoryGraphNodeDetailPayload {
|
||||
success: boolean
|
||||
node: {
|
||||
id: string
|
||||
type: 'entity'
|
||||
content: string
|
||||
hash?: string
|
||||
appearance_count?: number
|
||||
}
|
||||
relations: MemoryGraphRelationDetailPayload[]
|
||||
paragraphs: MemoryGraphParagraphDetailPayload[]
|
||||
evidence_graph: MemoryEvidenceGraphPayload
|
||||
}
|
||||
|
||||
export interface MemoryGraphEdgeDetailPayload {
|
||||
success: boolean
|
||||
edge: MemoryGraphEdgePayload
|
||||
relations: MemoryGraphRelationDetailPayload[]
|
||||
paragraphs: MemoryGraphParagraphDetailPayload[]
|
||||
evidence_graph: MemoryEvidenceGraphPayload
|
||||
}
|
||||
|
||||
export interface MemoryRuntimeConfigPayload {
|
||||
success: boolean
|
||||
config: Record<string, unknown>
|
||||
data_dir: string
|
||||
embedding_dimension: number
|
||||
auto_save: boolean
|
||||
relation_vectors_enabled: boolean
|
||||
runtime_ready: boolean
|
||||
embedding_degraded: boolean
|
||||
embedding_degraded_reason: string
|
||||
embedding_degraded_since?: number | null
|
||||
embedding_last_check?: number | null
|
||||
paragraph_vector_backfill_pending: number
|
||||
paragraph_vector_backfill_running: number
|
||||
paragraph_vector_backfill_failed: number
|
||||
paragraph_vector_backfill_done: number
|
||||
}
|
||||
|
||||
export interface MemoryRuntimeSelfCheckPayload {
|
||||
success: boolean
|
||||
report?: Record<string, unknown>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MemoryConfigPayload {
|
||||
success: boolean
|
||||
config: Record<string, unknown>
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface MemoryRawConfigPayload {
|
||||
success: boolean
|
||||
config: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface MemoryConfigSchemaPayload {
|
||||
success: boolean
|
||||
schema: PluginConfigSchema
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface MemoryImportGuidePayload {
|
||||
success: boolean
|
||||
content: string
|
||||
source?: string
|
||||
path?: string
|
||||
settings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MemoryTaskPayload {
|
||||
task_id?: string
|
||||
status?: string
|
||||
mode?: string
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MemoryTaskListPayload {
|
||||
success: boolean
|
||||
items: MemoryTaskPayload[]
|
||||
count?: number
|
||||
settings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MemoryTuningProfilePayload {
|
||||
success: boolean
|
||||
profile?: Record<string, unknown>
|
||||
settings?: Record<string, unknown>
|
||||
toml?: string
|
||||
}
|
||||
|
||||
export interface MemoryDeleteCountsPayload {
|
||||
relations?: number
|
||||
paragraphs?: number
|
||||
entities?: number
|
||||
sources?: number
|
||||
requested_sources?: number
|
||||
matched_sources?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface MemoryDeletePreviewItemPayload {
|
||||
item_type: string
|
||||
item_hash: string
|
||||
item_key?: string
|
||||
label?: string
|
||||
preview?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface MemoryDeleteRequestPayload {
|
||||
mode: string
|
||||
selector: Record<string, unknown> | string
|
||||
reason?: string
|
||||
requested_by?: string
|
||||
}
|
||||
|
||||
export interface MemoryDeletePreviewPayload {
|
||||
success: boolean
|
||||
mode: string
|
||||
selector: Record<string, unknown> | string
|
||||
counts: MemoryDeleteCountsPayload
|
||||
sources: string[]
|
||||
items: MemoryDeletePreviewItemPayload[]
|
||||
item_count: number
|
||||
dry_run?: boolean
|
||||
requested_source_count?: number
|
||||
matched_source_count?: number
|
||||
vector_ids?: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MemoryDeleteExecutePayload {
|
||||
success: boolean
|
||||
mode: string
|
||||
operation_id: string
|
||||
counts: MemoryDeleteCountsPayload
|
||||
sources: string[]
|
||||
deleted_count: number
|
||||
deleted_entity_count: number
|
||||
deleted_relation_count: number
|
||||
deleted_paragraph_count: number
|
||||
deleted_source_count: number
|
||||
deleted_vector_count?: number
|
||||
requested_source_count?: number
|
||||
matched_source_count?: number
|
||||
error?: string
|
||||
deleted?: boolean | number
|
||||
}
|
||||
|
||||
export interface MemoryDeleteOperationItemPayload {
|
||||
item_type: string
|
||||
item_hash: string
|
||||
item_key?: string
|
||||
payload?: Record<string, unknown>
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface MemoryDeleteOperationPayload {
|
||||
operation_id: string
|
||||
mode: string
|
||||
selector?: Record<string, unknown> | string
|
||||
reason?: string | null
|
||||
requested_by?: string | null
|
||||
status?: string
|
||||
created_at?: number
|
||||
restored_at?: number | null
|
||||
summary?: Record<string, unknown>
|
||||
items?: MemoryDeleteOperationItemPayload[]
|
||||
}
|
||||
|
||||
export interface MemoryDeleteOperationListPayload {
|
||||
success: boolean
|
||||
items: MemoryDeleteOperationPayload[]
|
||||
count?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MemoryDeleteOperationDetailPayload {
|
||||
success: boolean
|
||||
operation?: MemoryDeleteOperationPayload | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MemorySourceItemPayload {
|
||||
source: string
|
||||
paragraph_count?: number
|
||||
relation_count?: number
|
||||
episode_rebuild_blocked?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MemorySourceListPayload {
|
||||
success: boolean
|
||||
items: MemorySourceItemPayload[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export async function getMemoryGraph(limit: number = 120): Promise<MemoryGraphPayload> {
|
||||
return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`)
|
||||
}
|
||||
|
||||
export async function getMemoryGraphNodeDetail(
|
||||
nodeId: string,
|
||||
options?: {
|
||||
relationLimit?: number
|
||||
paragraphLimit?: number
|
||||
evidenceNodeLimit?: number
|
||||
},
|
||||
): Promise<MemoryGraphNodeDetailPayload> {
|
||||
const params = new URLSearchParams({
|
||||
node_id: nodeId,
|
||||
relation_limit: String(options?.relationLimit ?? 20),
|
||||
paragraph_limit: String(options?.paragraphLimit ?? 20),
|
||||
evidence_node_limit: String(options?.evidenceNodeLimit ?? 80),
|
||||
})
|
||||
return requestJson<MemoryGraphNodeDetailPayload>(`/graph/node-detail?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getMemoryGraphEdgeDetail(
|
||||
source: string,
|
||||
target: string,
|
||||
options?: {
|
||||
paragraphLimit?: number
|
||||
evidenceNodeLimit?: number
|
||||
},
|
||||
): Promise<MemoryGraphEdgeDetailPayload> {
|
||||
const params = new URLSearchParams({
|
||||
source,
|
||||
target,
|
||||
paragraph_limit: String(options?.paragraphLimit ?? 20),
|
||||
evidence_node_limit: String(options?.evidenceNodeLimit ?? 80),
|
||||
})
|
||||
return requestJson<MemoryGraphEdgeDetailPayload>(`/graph/edge-detail?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function previewMemoryDelete(
|
||||
payload: MemoryDeleteRequestPayload,
|
||||
): Promise<MemoryDeletePreviewPayload> {
|
||||
return requestJson<MemoryDeletePreviewPayload>('/delete/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeMemoryDelete(
|
||||
payload: MemoryDeleteRequestPayload,
|
||||
): Promise<MemoryDeleteExecutePayload> {
|
||||
return requestJson<MemoryDeleteExecutePayload>('/delete/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreMemoryDelete(payload: {
|
||||
operation_id: string
|
||||
mode?: string
|
||||
selector?: Record<string, unknown> | string
|
||||
reason?: string
|
||||
requested_by?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return requestJson('/delete/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryDeleteOperations(
|
||||
limit: number = 20,
|
||||
mode: string = '',
|
||||
): Promise<MemoryDeleteOperationListPayload> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (mode.trim()) {
|
||||
params.set('mode', mode)
|
||||
}
|
||||
return requestJson<MemoryDeleteOperationListPayload>(`/delete/operations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getMemoryDeleteOperation(
|
||||
operationId: string,
|
||||
): Promise<MemoryDeleteOperationDetailPayload> {
|
||||
return requestJson<MemoryDeleteOperationDetailPayload>(`/delete/operations/${encodeURIComponent(operationId)}`)
|
||||
}
|
||||
|
||||
export async function getMemorySources(): Promise<MemorySourceListPayload> {
|
||||
return requestJson<MemorySourceListPayload>('/sources')
|
||||
}
|
||||
|
||||
export async function getMemoryRuntimeConfig(): Promise<MemoryRuntimeConfigPayload> {
|
||||
return requestJson<MemoryRuntimeConfigPayload>('/runtime/config')
|
||||
}
|
||||
|
||||
export async function refreshMemoryRuntimeSelfCheck(): Promise<MemoryRuntimeSelfCheckPayload> {
|
||||
return requestJson<MemoryRuntimeSelfCheckPayload>('/runtime/self-check/refresh', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryConfigSchema(): Promise<MemoryConfigSchemaPayload> {
|
||||
return requestJson<MemoryConfigSchemaPayload>('/config/schema')
|
||||
}
|
||||
|
||||
export async function getMemoryConfig(): Promise<MemoryConfigPayload> {
|
||||
return requestJson<MemoryConfigPayload>('/config')
|
||||
}
|
||||
|
||||
export async function updateMemoryConfig(config: Record<string, unknown>): Promise<{ success: boolean; message?: string }> {
|
||||
return requestJson('/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryConfigRaw(): Promise<MemoryRawConfigPayload> {
|
||||
return requestJson<MemoryRawConfigPayload>('/config/raw')
|
||||
}
|
||||
|
||||
export async function updateMemoryConfigRaw(config: string): Promise<{ success: boolean; message?: string }> {
|
||||
return requestJson('/config/raw', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryImportGuide(): Promise<MemoryImportGuidePayload> {
|
||||
return requestJson<MemoryImportGuidePayload>('/import/guide')
|
||||
}
|
||||
|
||||
export async function getMemoryImportSettings(): Promise<Record<string, unknown>> {
|
||||
return requestJson('/import/settings')
|
||||
}
|
||||
|
||||
export async function getMemoryImportTasks(limit: number = 20): Promise<MemoryTaskListPayload> {
|
||||
return requestJson<MemoryTaskListPayload>(`/import/tasks?limit=${limit}`)
|
||||
}
|
||||
|
||||
export async function createMemoryPasteImport(payload: Record<string, unknown>): Promise<{ success: boolean; task?: MemoryTaskPayload }> {
|
||||
return requestJson('/import/paste', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryTuningProfile(): Promise<MemoryTuningProfilePayload> {
|
||||
return requestJson<MemoryTuningProfilePayload>('/retrieval_tuning/profile')
|
||||
}
|
||||
|
||||
export async function getMemoryTuningTasks(limit: number = 20): Promise<MemoryTaskListPayload> {
|
||||
return requestJson<MemoryTaskListPayload>(`/retrieval_tuning/tasks?limit=${limit}`)
|
||||
}
|
||||
|
||||
export async function createMemoryTuningTask(payload: Record<string, unknown>): Promise<{ success: boolean; task?: MemoryTaskPayload }> {
|
||||
return requestJson('/retrieval_tuning/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyBestMemoryTuningProfile(taskId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return requestJson(`/retrieval_tuning/tasks/${encodeURIComponent(taskId)}/apply-best`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMemoryTuningReport(taskId: string, format: 'md' | 'json' = 'md'): Promise<{ success: boolean; content: string; path: string; error?: string }> {
|
||||
return requestJson(`/retrieval_tuning/tasks/${encodeURIComponent(taskId)}/report?format=${format}`)
|
||||
}
|
||||
80
dashboard/src/routes/__tests__/plugin-config.test.tsx
Normal file
80
dashboard/src/routes/__tests__/plugin-config.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PluginConfigPage } from '../plugin-config'
|
||||
import * as pluginApi from '@/lib/plugin-api'
|
||||
|
||||
const toastMock = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-toast', () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/restart-context', () => ({
|
||||
RestartProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useRestart: () => ({
|
||||
showRestartPrompt: false,
|
||||
markRestartRequired: vi.fn(),
|
||||
clearRestartRequired: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/restart-overlay', () => ({
|
||||
RestartOverlay: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components', () => ({
|
||||
CodeEditor: ({ value }: { value: string }) => <pre>{value}</pre>,
|
||||
ListFieldEditor: () => <div>list-field-editor</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/plugin-api', () => ({
|
||||
getInstalledPlugins: vi.fn(),
|
||||
getPluginConfigSchema: vi.fn(),
|
||||
getPluginConfig: vi.fn(),
|
||||
getPluginConfigRaw: vi.fn(),
|
||||
updatePluginConfig: vi.fn(),
|
||||
updatePluginConfigRaw: vi.fn(),
|
||||
resetPluginConfig: vi.fn(),
|
||||
togglePlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('PluginConfigPage', () => {
|
||||
beforeEach(() => {
|
||||
toastMock.mockReset()
|
||||
vi.mocked(pluginApi.getInstalledPlugins).mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 'test.emoji',
|
||||
path: '/plugins/test_emoji',
|
||||
manifest: {
|
||||
manifest_version: 2,
|
||||
name: 'Emoji Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'emoji tools',
|
||||
author: { name: 'tester' },
|
||||
license: 'MIT',
|
||||
host_application: { min_version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(pluginApi.getPluginConfigSchema).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.getPluginConfig).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.getPluginConfigRaw).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.updatePluginConfig).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.updatePluginConfigRaw).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.resetPluginConfig).mockResolvedValue({} as never)
|
||||
vi.mocked(pluginApi.togglePlugin).mockResolvedValue({} as never)
|
||||
})
|
||||
|
||||
it('shows real plugins and no longer surfaces A_Memorix in plugin config list', async () => {
|
||||
render(<PluginConfigPage />)
|
||||
|
||||
expect(await screen.findByText('Emoji Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('点击插件查看和编辑配置')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/A_Memorix/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -76,6 +76,53 @@ interface FieldRendererProps {
|
||||
sectionName: string
|
||||
}
|
||||
|
||||
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
let current: unknown = config
|
||||
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current as Record<string, unknown>
|
||||
}
|
||||
|
||||
function setNestedField(
|
||||
config: Record<string, unknown>,
|
||||
path: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
const nextConfig: Record<string, unknown> = { ...config }
|
||||
let currentTarget = nextConfig
|
||||
let currentSource: Record<string, unknown> | undefined = config
|
||||
|
||||
for (const part of parts) {
|
||||
const sourceValue: unknown = currentSource?.[part]
|
||||
const nextValue =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? { ...(sourceValue as Record<string, unknown>) }
|
||||
: {}
|
||||
currentTarget[part] = nextValue
|
||||
currentTarget = nextValue
|
||||
currentSource =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? (sourceValue as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
currentTarget[fieldName] = value
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
@@ -91,7 +138,7 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
checked={Boolean(value ?? field.default)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
@@ -222,7 +269,7 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<ListFieldEditor
|
||||
value={Array.isArray(value) ? value : []}
|
||||
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
|
||||
onChange={(newValue) => onChange(newValue)}
|
||||
itemType={field.item_type ?? 'string'}
|
||||
itemFields={field.item_fields}
|
||||
@@ -267,6 +314,7 @@ interface SectionRendererProps {
|
||||
|
||||
function SectionRenderer({ section, config, onChange }: SectionRendererProps) {
|
||||
const [isOpen, setIsOpen] = useState(!section.collapsed)
|
||||
const sectionConfig = getNestedRecord(config, section.name)
|
||||
|
||||
// 按 order 排序字段
|
||||
const sortedFields = Object.entries(section.fields)
|
||||
@@ -304,7 +352,7 @@ function SectionRenderer({ section, config, onChange }: SectionRendererProps) {
|
||||
<FieldRenderer
|
||||
key={fieldName}
|
||||
field={field}
|
||||
value={(config[section.name] as Record<string, unknown>)?.[fieldName]}
|
||||
value={sectionConfig?.[fieldName]}
|
||||
onChange={(value) => onChange(section.name, fieldName, value)}
|
||||
sectionName={section.name}
|
||||
/>
|
||||
@@ -405,13 +453,7 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||
|
||||
// 处理字段变化
|
||||
const handleFieldChange = (sectionName: string, fieldName: string, value: unknown) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[sectionName]: {
|
||||
...(prev[sectionName] as Record<string, unknown> || {}),
|
||||
[fieldName]: value
|
||||
}
|
||||
}))
|
||||
setConfig(prev => setNestedField(prev, sectionName, fieldName, value))
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
|
||||
270
dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx
Normal file
270
dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
341
dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx
Normal file
341
dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx
Normal 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
@@ -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