Merge branch 'develop' into feature/ui_upgrade_zy

This commit is contained in:
zhaoying
2026-03-20 11:49:00 +08:00
286 changed files with 23406 additions and 5328 deletions

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:57:11
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-19 11:38:17
* @Last Modified time: 2026-03-20 11:27:53
*/
/**
* RAG User Memory Detail View
@@ -12,7 +12,8 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Row, Col, Skeleton, Flex } from 'antd'
import { Row, Col, Skeleton, Spin, Flex, Tooltip } from 'antd'
import { LoadingOutlined } from '@ant-design/icons';
import { useParams } from 'react-router-dom'
import aboutUs from '@/assets/images/userMemory/aboutUs.svg'
@@ -23,6 +24,7 @@ import {
getChunkSummaryTag,
getUserProfile,
getChunkInsight,
generateRagProfile
} from '@/api/memory'
import Empty from '@/components/Empty'
import ConversationMemory from './components/ConversationMemory'
@@ -96,6 +98,20 @@ const Rag: FC = () => {
})
}
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
const [refreshLoading, setRefreshLoading] = useState(false)
const handleRefresh = () => {
if (refreshLoading || !id) return
setRefreshLoading(true)
generateRagProfile(id as string)
.then(() => {
getSummary()
getInsightReport()
})
.finally(() => {
setRefreshLoading(false)
})
}
return (
<Row gutter={[16, 16]}>
<Col span={8}>
@@ -104,9 +120,22 @@ const Rag: FC = () => {
>
<Flex align="center" gap={12} className="rb:mb-6!">
<div className="rb:size-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-xl rb:text-white rb:bg-[#155EEF]">{name?.[0]}</div>
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
{name}
</div>
<Flex justify="space-between">
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
{name}
</div>
<Tooltip title={t('common.refresh')}>
{refreshLoading
? <Spin indicator={<LoadingOutlined spin />} />
: (
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh.svg')]"
onClick={handleRefresh}
></div>
)
}
</Tooltip>
</Flex>
</Flex>
{/* About Me */}

View File

@@ -0,0 +1,86 @@
import React, { useState, type FC, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Spin, Flex } from 'antd';
import type { CommunityD3Node, CommunityGraphData, RawCommunityGraphData, RawCommunityNode } from '@/components/D3Graph/types'
import { buildCommunityGraphData } from '@/components/D3Graph/utils'
import CommunityGraph from '@/components/D3Graph/CommunityGraph'
import { getMemoryCommunityGraph } from '@/api/memory'
// ─── Tooltip ──────────────────────────────────────────────────────────────────
const NodeTooltip: FC<{ node: CommunityD3Node }> = ({ node }) => {
const { t } = useTranslation()
return (
<div style={{
background: '#fff', border: '1px solid #DFE4ED', borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: '10px 14px',
minWidth: 180, maxWidth: 260, fontSize: 13,
}}>
<div style={{ fontWeight: 600, marginBottom: 6, color: '#1a1a1a', fontSize: 14 }}>
{node.properties?.name ?? node.name}
</div>
{node.properties?.description && (
<div style={{ color: '#5B6167', lineHeight: '20px', marginBottom: 4 }}>
{node.properties.description}
</div>
)}
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
{t('userMemory.type')}
<span style={{ color: '#1a1a1a' }}>{t(`userMemory.${node.properties?.entity_type}`)}</span>
</div>
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
{t('userMemory.community')}
<span style={{ color: node.color, fontWeight: 500 }}>{node.properties?.community_name}</span>
</div>
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
const CommunityNetwork: FC<{ onSelectCommunity?: (node: RawCommunityNode) => void }> = ({ onSelectCommunity }) => {
const { id } = useParams()
const { t } = useTranslation()
const [graphData, setGraphData] = useState<CommunityGraphData | null>(null)
const [empty, setEmpty] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!id) return
const controller = new AbortController()
setEmpty(false)
setGraphData(null)
setLoading(true)
getMemoryCommunityGraph(id, { signal: controller.signal }).then(res => {
const raw = res as RawCommunityGraphData
if (!raw.nodes?.length) { setEmpty(true); return }
const built = buildCommunityGraphData(raw)
if (!built) { setEmpty(true); return }
setGraphData(built)
}).catch((e) => { if (e?.code !== 'ERR_CANCELED') setEmpty(true) })
.finally(() => setLoading(false))
return () => controller.abort()
}, [id])
if (loading) {
return <Flex align="center" justify="center" className="rb:w-full rb:h-full">
<Spin tip={t('userMemory.communityLoadingTip')} size="large">
<div className="rb:w-64 rb:h-64" />
</Spin>
</Flex>
}
return (
<CommunityGraph
data={graphData}
empty={empty}
showLegend={false}
onCommunityClick={onSelectCommunity}
renderTooltip={node => <NodeTooltip node={node} />}
/>
)
}
export default React.memo(CommunityNetwork)

View File

@@ -1,74 +1,43 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:34:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:34:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 11:04:52
*/
/**
* Conversation Memory Component
* Displays RAG conversation memory content list
*/
import { type FC, useEffect, useState } from 'react'
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, List } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import PageScrollList from '@/components/PageScrollList'
import Markdown from '@/components/Markdown'
import {
getRagContent
} from '@/api/memory'
import { getRagContentUrl } from '@/api/memory'
const ConversationMemory:FC = () => {
const ConversationMemory: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(true)
const [list, setList] = useState<string[]>([])
useEffect(() => {
if (!id) return
getList()
}, [id])
/** Fetch conversation memory list */
const getList = () => {
if (!id) return
setLoading(true)
getRagContent(id).then((res) => {
setList((res as { contents?: [] }).contents || [])
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
<RbCard
title={t('userMemory.conversationMemory')}
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName="rb:h-[100%]! rb:overflow-hidden rb:py-0!"
>
{loading
? <Skeleton />
: list.length > 0
? <List
dataSource={list}
grid={{ gutter: 12, column: 1 }}
renderItem={(item, index) => (
<List.Item>
<div
key={index}
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
>
<Markdown content={item} />
</div>
</List.Item>
)}
/>
: <Empty className="rb:h-full" />
}
<PageScrollList<string>
url={getRagContentUrl}
query={{ end_user_id: id }}
column={1}
renderItem={(item: string) => (
<div
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
>
<Markdown content={item} />
</div>
)}
className="rb:h-full!"
/>
</RbCard>
)
}
export default ConversationMemory
export default ConversationMemory

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:01:50
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 11:07:02
*/
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -107,7 +107,7 @@ const PerceptualLastInfo: FC = () => {
})
}
const handleDownload = () => {
const handleDownload = async () => {
if (!data.file_path) return
window.open(data.file_path, '_blank')
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:00
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-11 15:06:05
* @Last Modified time: 2026-03-20 11:45:16
*/
/**
* Relationship Network Component
@@ -10,51 +10,60 @@
* Interactive force-directed graph visualization
*/
import React, { type FC, useEffect, useState, useCallback } from 'react'
import React, { type FC, useEffect, useState, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Space, Flex } from 'antd'
import { Space, Tabs, Flex, Divider } from 'antd'
import dayjs from 'dayjs'
import RbCard from '@/components/RbCard/Card'
import type { GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
import type { RawCommunityNode } from '@/components/D3Graph/types'
import {
getMemorySearchEdges,
} from '@/api/memory'
import Tag from '@/components/Tag'
import GraphNetworkChart, { type Node, type Edge } from '@/components/Charts/GraphNetworkChart'
import CommunityNetwork from './CommunityNetwork'
const RelationshipNetwork:FC = () => {
const RelationshipNetwork: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [nodes, setNodes] = useState<Node[]>([])
const [links, setLinks] = useState<Edge[]>([])
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
const [selectedNode, setSelectedNode] = useState<Node | RawCommunityNode | null>(null)
// const [fullScreen, setFullScreen] = useState<boolean>(false)
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('relationshipNetwork')
console.log('categories', categories)
const edgeAbortRef = useRef<AbortController | null>(null)
/** Fetch relationship network data */
const getEdgeData = useCallback(() => {
if (!id) return
edgeAbortRef.current?.abort()
edgeAbortRef.current = new AbortController()
setSelectedNode(null)
getMemorySearchEdges(id).then((res) => {
getMemorySearchEdges(id, { signal: edgeAbortRef.current.signal }).then((res) => {
const { nodes, edges, statistics } = res as GraphData
const curNodes: Node[] = []
const curEdges: Edge[] = []
const curNodeTypes = Object.keys(statistics.node_types).filter(vo => vo !== 'Dialogue')
// Calculate connection count for each node
const connectionCount: Record<string, number> = {}
edges.forEach(edge => {
connectionCount[edge.source] = (connectionCount[edge.source] || 0) + 1
connectionCount[edge.target] = (connectionCount[edge.target] || 0) + 1
})
// Process node data
nodes.filter(vo => vo.label !== 'Dialogue').forEach(node => {
const connections = connectionCount[node.id] || 0
const categoryIndex = curNodeTypes.indexOf(node.label)
// Get display name based on node type
let displayName = ''
switch (node.label) {
@@ -80,7 +89,7 @@ const RelationshipNetwork:FC = () => {
} else {
symbolSize = 35
}
curNodes.push({
...node,
name: displayName,
@@ -88,7 +97,7 @@ const RelationshipNetwork:FC = () => {
symbolSize: symbolSize, // Adjust node size based on connection count
})
})
// Create mapping from node ID to label
const nodeIdToLabel: Record<string, string> = {}
nodes.forEach(node => {
@@ -103,10 +112,10 @@ const RelationshipNetwork:FC = () => {
value: edge.weight || 1
})
})
// Set categories
const curCategories = curNodeTypes.map(type => ({ name: type }))
setNodes(curNodes)
setLinks(curEdges)
setCategories(curCategories)
@@ -115,6 +124,7 @@ const RelationshipNetwork:FC = () => {
useEffect(() => {
if (!id) return
getEdgeData()
return () => { edgeAbortRef.current?.abort() }
}, [id])
/** Navigate to full graph view */
@@ -123,21 +133,41 @@ const RelationshipNetwork:FC = () => {
const params = new URLSearchParams({
nodeId: selectedNode.id,
nodeLabel: selectedNode.label,
nodeName: selectedNode.name || ''
nodeName: (selectedNode as Node).name || ''
})
navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`)
}
const handleChangeTab = (tab: string) => {
if (tab === 'communityNetwork') {
edgeAbortRef.current?.abort()
} else {
getEdgeData()
}
setActiveTab(tab)
setSelectedNode(null)
}
return (
<div className="rb:flex-1 rb:relative">
<GraphNetworkChart
nodes={nodes}
links={links}
categories={categories.map(vo => ({
name: t(`userMemory.${vo.name}`)
})) || []}
onNodeClick={setSelectedNode}
<Tabs
activeKey={activeTab}
onChange={handleChangeTab}
items={['relationshipNetwork', 'communityNetwork'].map(key => ({
key,
label: t(`userMemory.${key}`)
}))}
/>
{activeTab === 'communityNetwork'
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
: <GraphNetworkChart
nodes={nodes}
links={links}
categories={categories.map(vo => ({
name: t(`userMemory.${vo.name}`)
})) || []}
onNodeClick={(node) => setSelectedNode(node as Node)}
/>
}
{selectedNode &&
<RbCard
title={t('userMemory.memoryDetails')}
@@ -148,82 +178,100 @@ const RelationshipNetwork:FC = () => {
extra={<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/close.svg')]" onClick={() => setSelectedNode(null)}></div>}
>
<div className="rb:max-h-[calc(100vh-269px)] rb:overflow-auto">
{selectedNode.name &&
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
{selectedNode.name}
</div>
}
<Flex vertical gap={24}>
<div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
? selectedNode.properties.content
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
? selectedNode.properties.description
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
? selectedNode.properties.statement
: ''
}
{(selectedNode as RawCommunityNode).properties.community_id
? <div className="rb:p-3 rb:pt-0">
<div className="rb:font-medium rb:text-[#212332] rb:text-[16px] rb:leading-5.5 rb:pl-1">
{(selectedNode as RawCommunityNode).properties.name}
</div>
</div>
<div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
<div className="rb:mt-3 rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.summary')}</div>
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:px-3 rb:py-2.5 rb:mt-2">
{(selectedNode as RawCommunityNode).properties.summary}
</div>
<Flex align="center" justify="space-between" className="rb:mt-5!">
<span className="rb:text-[#5B6167] rb:font-regular rb:pl-1">{t('userMemory.member_count')}</span>
<span className="rb:font-medium">{(selectedNode as RawCommunityNode).properties.member_count}{t('userMemory.member_count_desc')}</span>
</Flex>
<Divider className='rb:my-2.5!' />
<div className="rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.core_entities')}</div>
<ul className="rb:list-disc rb:pl-4 rb:text-[#5B6167] rb:mt-2">
{(selectedNode as RawCommunityNode).properties.core_entities.map((entity, index) => <li key={index}>{entity}</li>)}
</ul>
</div>
: <>
{(selectedNode as Node).name &&
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
{(selectedNode as Node).name}
</div>
}
<Flex vertical gap={24}>
<div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
? selectedNode.properties.content
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
? selectedNode.properties.description
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
? selectedNode.properties.statement
: ''
}
</div>
</div>
{selectedNode?.properties.associative_memory > 0 && <div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
</div>
</div>}
<div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
</div>
{(selectedNode as Node).properties.associative_memory > 0 && <div>
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
<span className="rb:text-[#155EEF] rb:font-medium">{(selectedNode as Node).properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
</div>
</div>}
{selectedNode.label === 'Statement' && <>
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
const statementProps = selectedNode.properties as StatementNodeProperties;
if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || typeof statementProps[key] === 'string') {
return (
<div key={key}>
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{key === 'emotion_keywords'
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
: statementProps[key]
}
</div>
</div>
)
}
return null
})}
{selectedNode.label === 'Statement' && (<>
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
const p = selectedNode.properties as StatementNodeProperties
if ((key === 'emotion_keywords' && p[key]?.length > 0) || typeof p[key] === 'string') {
return (
<div key={key}>
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{key === 'emotion_keywords'
? <Space>{p.emotion_keywords.map((v, i) => <Tag key={i}>{v}</Tag>)}</Space>
: p[key]}
</div>
</div>
)
}
return null
})}
</>)}
{selectedNode.label === 'ExtractedEntity' && <>
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
const p = selectedNode.properties as ExtractedEntityNodeProperties
if (p[key]) {
return (
<div key={key}>
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{Array.isArray(p[key]) && p[key].length > 0
? p[key].map((v, i) => <div key={i}>- {v}</div>)
: p[key]}
</div>
</div>
)
}
return null
})}
</>}
</Flex>
</>}
{selectedNode.label === 'ExtractedEntity' && <>
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
const entityProps = selectedNode.properties as ExtractedEntityNodeProperties;
if (entityProps[key]) {
return (
<div key={key}>
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
{Array.isArray(entityProps[key]) && entityProps[key].length > 0
? entityProps[key].map((vo, index) => <div key={index}>- {vo}</div>)
: entityProps[key]
}
</div>
</div>
)
}
return null
})}
</>}
</Flex>
</div>
<Flex align="center" justify="center" className="rb:absolute rb:bottom-3 rb:left-6 rb:right-6 rb:border rb:border-[#171719] rb:rounded-xl rb:h-11 rb:font-medium rb:leading-5 rb:cursor-pointer" onClick={handleViewAll}>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 17:57:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 17:57:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 11:49:52
*/
/**
* User Memory Detail Types
@@ -90,6 +90,7 @@ export interface ExtractedEntityNodeProperties {
connect_strngth: string;
importance_score: number;
associative_memory: number;
community_name?: string;
}
/**
* Memory summary node
@@ -246,4 +247,53 @@ export interface ForgetData {
*/
export interface GraphDetailRef {
handleOpen: (vo: Node) => void
}
}
// Community
export type CommunityNodeType = 'Community' | 'ExtractedEntity';
export type CommunityEdgeType = 'BELONGS_TO_COMMUNITY' | 'EXTRACTED_RELATIONSHIP';
export type CommunityEntityType = "Person" | "Organization" | "ORG" | "Location" | "LOC" | "Event" | "Concept" | "Time" | "Position" | "WorkRole" | "System" | "Policy" | "HistoricalPeriod" | "HistoricalState" | "HistoricalEvent" | "EconomicFactor" | "Condition" | "Numeric" | "Work";
// 社区节点
export interface CommunityTypeNode {
id: string;
label: 'Community';
properties: {
community_id: string;
end_user_id: string;
member_count: number;
updated_at: string;
name: string;
summary: string;
core_entities: string[];
member_entity_ids: string[];
};
}
// 核心实体
export interface ExtractedEntityTypeNode {
id: string;
label: 'ExtractedEntity';
properties: {
name: string;
end_user_id: string;
description: string;
created_at: string;
entity_type: CommunityEntityType;
community_name: string;
};
}
// 社区图谱连线
export interface CommunityEdge {
id: string;
target: string;
source: string;
}
export interface CommunityStatistics {
total_nodes: number;
total_edges: number;
node_types: Record<CommunityNodeType, number>;
edge_types: Record<CommunityEdgeType, number>;
}
export interface CommunityGraphData {
nodes: (CommunityTypeNode | ExtractedEntityTypeNode)[];
edges: CommunityEdge[];
statistics: CommunityStatistics;
}