feat: Add base project structure with API and web components
This commit is contained in:
190
web/src/views/UserMemoryDetail/Neo4j.tsx
Normal file
190
web/src/views/UserMemoryDetail/Neo4j.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
import { Row, Col, Skeleton } from 'antd'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import aboutUs from '@/assets/images/userMemory/aboutUs.svg'
|
||||
import down from '@/assets/images/userMemory/down.svg'
|
||||
import interestDistribution from '@/assets/images/userMemory/interestDistribution.svg'
|
||||
import PieCard from './components/PieCard'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import type { Data } from './types'
|
||||
import {
|
||||
getUserSummary,
|
||||
getUserProfile,
|
||||
getTotalMemoryCountByUser,
|
||||
} from '@/api/memory'
|
||||
import RelationshipNetwork from './components/RelationshipNetwork'
|
||||
import MemoryInsight from './components/MemoryInsight'
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
const tagColors = ['21, 94, 239', '156, 111, 255', '255, 93, 52', '54, 159, 33']
|
||||
|
||||
interface TitleProps {
|
||||
type: string;
|
||||
title: string
|
||||
icon: string
|
||||
t: (key: string) => string;
|
||||
expanded: boolean;
|
||||
onClick: (type: string) => void;
|
||||
}
|
||||
const Title: FC<TitleProps> = ({ type, title, icon, t, expanded, onClick }) => (
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:py-[17px] rb:border-b-[1px] rb:border-[#DFE4ED] rb:text-[16px] rb:font-semibold rb:leading-[22px]">
|
||||
<span className="rb:flex rb:items-center">
|
||||
<img src={icon} className="rb:w-[20px] rb:h-[20px] rb:mr-[8px]" />
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<span className="rb:flex rb:items-center rb:cursor-pointer rb:text-[#5B6167] rb:text-[14px] rb:font-regular rb:leading-[20px]" onClick={() => onClick(type)}>
|
||||
{t(`userMemory.${expanded ? 'foldUp' : 'expanded'}`)}
|
||||
<img src={down} className={clsx("rb:w-[16px] rb:h-[16px] rb:ml-[4px]", {
|
||||
'rb:rotate-180': !expanded,
|
||||
})} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Neo4j: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [expanded, setExpanded] = useState<string[]>(['aboutUs', 'interestDistribution', 'importantRelationships', 'importantMomentsInLife'])
|
||||
const [summary, setSummary] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({
|
||||
detail: false,
|
||||
summary: false,
|
||||
})
|
||||
const [memory, setMemory] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getMemory()
|
||||
getSummary()
|
||||
getDetail()
|
||||
}, [id])
|
||||
|
||||
const handleTitleClick = (key: string) => {
|
||||
setExpanded(expanded.includes(key) ? expanded.filter((item) => item !== key) : [...expanded, key])
|
||||
}
|
||||
// 用户记忆详情
|
||||
const getDetail = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, detail: true }))
|
||||
getUserProfile(id).then((res) => {
|
||||
setData((res as Data))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, detail: false }))
|
||||
})
|
||||
}
|
||||
// 记忆总览
|
||||
const getMemory = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, memory: true }))
|
||||
getTotalMemoryCountByUser(id).then((res) => {
|
||||
setMemory(res.total)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, memory: false }))
|
||||
})
|
||||
}
|
||||
// 用户摘要
|
||||
const getSummary = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, summary: true }))
|
||||
getUserSummary(id).then((res) => {
|
||||
setSummary((res as { summary?: string }).summary || null)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, summary: false }))
|
||||
})
|
||||
}
|
||||
|
||||
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
|
||||
return (
|
||||
<Row gutter={[16, 16]} className="rb:pb-[24px]">
|
||||
<Col span={8}>
|
||||
<RbCard>
|
||||
<div className="rb:flex rb:items-center">
|
||||
<div className="rb:flex-[0_0_auto] rb:w-[80px] rb:h-[80px] rb:text-center rb:font-semibold rb:text-[28px] rb:leading-[80px] rb:rounded-[8px] rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name?.[0]}</div>
|
||||
<div className="rb:text-[24px] rb:font-semibold rb:leading-[32px] rb:ml-[16px]">
|
||||
{name}<br/>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] rb:mt-[8px]">{data?.tags?.join(' | ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:flex rb:gap-[8px] rb:mb-[8px] rb:flex-wrap rb:mt-[25px]">
|
||||
{data?.hot_tags?.map((tag, tagIndex) => (
|
||||
<span key={tag} className="rb:rounded-[11px] rb:p-[0_8px] rb:leading-[22px] rb:border"
|
||||
style={{
|
||||
backgroundColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.08)`,
|
||||
borderColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.3)`,
|
||||
color: `rgba(${tagColors[tagIndex % tagColors.length]}, 1)`,
|
||||
}}
|
||||
>
|
||||
{tag.name}({tag.frequency})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 记忆总量 */}
|
||||
<div className="rb:font-regular rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px] rb:mb-[25px]">
|
||||
{t('userMemory.totalNumOfMemories')}
|
||||
<div className="rb:font-extrabold rb:text-[24px] rb:text-[#212332] rb:leading-[30px] rb:mt-[8px]">{memory || 0}</div>
|
||||
</div>
|
||||
|
||||
{/* 关于我 */}
|
||||
<>
|
||||
<Title
|
||||
type="aboutUs"
|
||||
title={t('userMemory.aboutMe')}
|
||||
icon={aboutUs}
|
||||
t={t}
|
||||
expanded={expanded.includes('aboutUs')}
|
||||
onClick={handleTitleClick}
|
||||
/>
|
||||
{expanded.includes('aboutUs') && (
|
||||
<>
|
||||
{loading.summary
|
||||
? <Skeleton className="rb:mt-[16px]" />
|
||||
: summary
|
||||
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
|
||||
{summary || '-'}
|
||||
</div>
|
||||
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* 兴趣分布 */}
|
||||
<>
|
||||
<Title
|
||||
type="interestDistribution"
|
||||
title={t('userMemory.interestDistribution')}
|
||||
icon={interestDistribution}
|
||||
t={t}
|
||||
expanded={expanded.includes('interestDistribution')}
|
||||
onClick={handleTitleClick}
|
||||
/>
|
||||
|
||||
{expanded.includes('interestDistribution') && (
|
||||
<PieCard />
|
||||
)}
|
||||
</>
|
||||
</RbCard>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 记忆洞察 */}
|
||||
<Col span={24}>
|
||||
<MemoryInsight />
|
||||
</Col>
|
||||
{/* 关系网络 + 记忆详情 */}
|
||||
<RelationshipNetwork />
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
export default Neo4j
|
||||
203
web/src/views/UserMemoryDetail/Rag.tsx
Normal file
203
web/src/views/UserMemoryDetail/Rag.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
import { Row, Col, Skeleton } from 'antd'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import aboutUs from '@/assets/images/userMemory/aboutUs.svg'
|
||||
import down from '@/assets/images/userMemory/down.svg'
|
||||
import interestDistribution from '@/assets/images/userMemory/interestDistribution.svg'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import type { Data } from './types'
|
||||
import {
|
||||
getChunkSummaryTag,
|
||||
getUserProfile,
|
||||
getTotalRagMemoryCountByUser,
|
||||
getChunkInsight,
|
||||
} from '@/api/memory'
|
||||
import Empty from '@/components/Empty'
|
||||
import ConversationMemory from './components/ConversationMemory'
|
||||
|
||||
const tagColors = ['21, 94, 239', '156, 111, 255', '255, 93, 52', '54, 159, 33']
|
||||
|
||||
interface TitleProps {
|
||||
type: string;
|
||||
title: string
|
||||
icon: string
|
||||
t: (key: string) => string;
|
||||
expanded: boolean;
|
||||
onClick: (type: string) => void;
|
||||
}
|
||||
const Title: FC<TitleProps> = ({ type, title, icon, t, expanded, onClick }) => (
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:py-[17px] rb:border-b-[1px] rb:border-[#DFE4ED] rb:text-[16px] rb:font-semibold rb:leading-[22px]">
|
||||
<span className="rb:flex rb:items-center">
|
||||
<img src={icon} className="rb:w-[20px] rb:h-[20px] rb:mr-[8px]" />
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<span className="rb:flex rb:items-center rb:cursor-pointer rb:text-[#5B6167] rb:text-[14px] rb:font-regular rb:leading-[20px]" onClick={() => onClick(type)}>
|
||||
{t(`userMemory.${expanded ? 'foldUp' : 'expanded'}`)}
|
||||
<img src={down} className={clsx("rb:w-[16px] rb:h-[16px] rb:ml-[4px]", {
|
||||
'rb:rotate-180': !expanded,
|
||||
})} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Rag: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [expanded, setExpanded] = useState<string[]>(['aboutUs', 'memoryInsight',])
|
||||
const [summary, setSummary] = useState<string | null>('')
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({
|
||||
detail: true,
|
||||
summary: true,
|
||||
insight: true,
|
||||
})
|
||||
const [memory, setMemory] = useState<number | null>(null)
|
||||
const [insight, setInsight] = useState<string | null>('')
|
||||
const [tags, setTags] = useState<{ tag: string; frequency: number }[]>([])
|
||||
const [personas, setPersonas] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getMemory()
|
||||
getSummary()
|
||||
getDetail()
|
||||
getInsightReport()
|
||||
}, [id])
|
||||
|
||||
const handleTitleClick = (key: string) => {
|
||||
setExpanded(expanded.includes(key) ? expanded.filter((item) => item !== key) : [...expanded, key])
|
||||
}
|
||||
// 用户记忆详情
|
||||
const getDetail = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, detail: true }))
|
||||
getUserProfile(id).then((res) => {
|
||||
setData((res as Data))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, detail: false }))
|
||||
})
|
||||
}
|
||||
// 记忆总览
|
||||
const getMemory = () => {
|
||||
if (!id) return
|
||||
getTotalRagMemoryCountByUser(id).then((res) => {
|
||||
setMemory(res as number || 0)
|
||||
})
|
||||
}
|
||||
// 用户摘要
|
||||
const getSummary = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, summary: true }))
|
||||
getChunkSummaryTag(id).then((res) => {
|
||||
const response = res as { summary?: string; tags?: { tag: string; frequency: number }[]; personas?: string[] }
|
||||
setSummary(response.summary || null)
|
||||
setTags(response.tags || [])
|
||||
setPersonas(response.personas || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, summary: false }))
|
||||
})
|
||||
}
|
||||
// 记忆洞察
|
||||
const getInsightReport = () => {
|
||||
if (!id) return
|
||||
setLoading(prev => ({ ...prev, insight: true }))
|
||||
getChunkInsight(id).then((res) => {
|
||||
setInsight((res as { insight?: string }).insight || null)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(prev => ({ ...prev, insight: false }))
|
||||
})
|
||||
}
|
||||
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
|
||||
return (
|
||||
<Row gutter={[16, 16]} className="rb:pb-[24px]">
|
||||
<Col span={8}>
|
||||
<RbCard>
|
||||
<div className="rb:flex rb:items-center">
|
||||
<div className="rb:flex-[0_0_auto] rb:w-[80px] rb:h-[80px] rb:text-center rb:font-semibold rb:text-[28px] rb:leading-[80px] rb:rounded-[8px] rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name?.[0]}</div>
|
||||
<div className="rb:text-[24px] rb:font-semibold rb:leading-[32px] rb:ml-[16px]">
|
||||
{name}<br/>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] rb:mt-[8px]">{personas?.join(' | ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:flex rb:gap-[8px] rb:mb-[8px] rb:flex-wrap rb:mt-[25px]">
|
||||
{tags?.map((tag, tagIndex) => (
|
||||
<span key={tag.tag} className="rb:rounded-[11px] rb:p-[0_8px] rb:leading-[22px] rb:border"
|
||||
style={{
|
||||
backgroundColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.08)`,
|
||||
borderColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.3)`,
|
||||
color: `rgba(${tagColors[tagIndex % tagColors.length]}, 1)`,
|
||||
}}
|
||||
>
|
||||
{tag.tag}({tag.frequency})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 记忆总量 */}
|
||||
<div className="rb:font-regular rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px] rb:mb-[25px]">
|
||||
{t('userMemory.totalNumOfMemories')}
|
||||
<div className="rb:font-extrabold rb:text-[24px] rb:text-[#212332] rb:leading-[30px] rb:mt-[8px]">{memory || 0}</div>
|
||||
</div>
|
||||
|
||||
{/* 关于我 */}
|
||||
<>
|
||||
<Title
|
||||
type="aboutUs"
|
||||
title={t('userMemory.aboutMe')}
|
||||
icon={aboutUs}
|
||||
t={t}
|
||||
expanded={expanded.includes('aboutUs')}
|
||||
onClick={handleTitleClick}
|
||||
/>
|
||||
{expanded.includes('aboutUs') && (
|
||||
<>
|
||||
{loading.summary
|
||||
? <Skeleton className="rb:mt-[16px]" />
|
||||
: summary
|
||||
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
|
||||
{summary || '-'}
|
||||
</div>
|
||||
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{/* 记忆洞察 */}
|
||||
<>
|
||||
<Title
|
||||
type="memoryInsight"
|
||||
title={t('userMemory.memoryInsight')}
|
||||
icon={interestDistribution}
|
||||
t={t}
|
||||
expanded={expanded.includes('memoryInsight')}
|
||||
onClick={handleTitleClick}
|
||||
/>
|
||||
{expanded.includes('memoryInsight') && (
|
||||
<>
|
||||
{loading.insight
|
||||
? <Skeleton className="rb:mt-[16px]" />
|
||||
: insight
|
||||
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
|
||||
{insight || '-'}
|
||||
</div>
|
||||
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</RbCard>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<ConversationMemory />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
export default Rag
|
||||
23
web/src/views/UserMemoryDetail/components/Card.tsx
Normal file
23
web/src/views/UserMemoryDetail/components/Card.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
theme?: 'default' | 'custom';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Card: FC<CardProps> = ({ title, children, theme = 'default', className }) => {
|
||||
return (
|
||||
<div className={clsx('rb:h-full rb:border rb:rounded-[12px] rb:p-[16px] rb:border-[#DFE4ED]', {
|
||||
'rb:bg-[#FBFDFF]': theme === 'default',
|
||||
'rb:bg-[linear-gradient(180deg,_#F1F9FE_0%,_#FBFCFF_100%)]': theme === 'custom',
|
||||
}, className)}>
|
||||
{title && <div className="rb:text-[18px] rb:font-semibold rb:leading-[25px] rb:pb-[16px]">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Skeleton } from 'antd';
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import Empty from '@/components/Empty';
|
||||
import { List } from 'antd';
|
||||
import Markdown from '@/components/Markdown'
|
||||
import {
|
||||
getRagContent
|
||||
} from '@/api/memory'
|
||||
|
||||
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])
|
||||
const getList = () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getRagContent(id).then((res) => {
|
||||
setList((res as { contents?: [] }).contents || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<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:border-[#DFE4ED] rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:rounded-lg rb:mt-2 rb:text-gray-800 rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
: <Empty className="rb:h-full" />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
export default ConversationMemory
|
||||
55
web/src/views/UserMemoryDetail/components/MemoryInsight.tsx
Normal file
55
web/src/views/UserMemoryDetail/components/MemoryInsight.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Skeleton } from 'antd';
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import Empty from '@/components/Empty';
|
||||
import {
|
||||
getMemoryInsightReport,
|
||||
} from '@/api/memory'
|
||||
|
||||
const MemoryInsight:FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [report, setReport] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getInsightReport()
|
||||
}, [id])
|
||||
|
||||
// 记忆洞察
|
||||
const getInsightReport = () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getMemoryInsightReport(id).then((res) => {
|
||||
setReport((res as { report?: string }).report || null)
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<RbCard
|
||||
title={t('userMemory.memoryInsight')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
bgColor="linear-gradient(180deg,#F1F9FE 0%, #FBFCFF 100%)"
|
||||
height="100%"
|
||||
>
|
||||
{loading
|
||||
? <Skeleton />
|
||||
: report
|
||||
? <div className="rb:flex rb:flex-wrap rb:justify-between rb:h-full">
|
||||
<div className="rb:leading-[22px]">
|
||||
{report|| '-'}
|
||||
</div>
|
||||
</div>
|
||||
: <Empty size={80} />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
export default MemoryInsight
|
||||
128
web/src/views/UserMemoryDetail/components/PieCard.tsx
Normal file
128
web/src/views/UserMemoryDetail/components/PieCard.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { type FC, useRef, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
import { getHotMemoryTagsByUser } from '@/api/memory';
|
||||
import Empty from '@/components/Empty';
|
||||
import Loading from '@/components/Empty/Loading';
|
||||
|
||||
const Colors = ['#155EEF', '#4DA8FF', '#03BDFF', '#31E8FF', '#AD88FF', '#FFB048']
|
||||
|
||||
const PieCard: FC = () => {
|
||||
const { id } = useParams()
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<Array<Record<string, string | number>>>([])
|
||||
|
||||
useEffect(() => {
|
||||
getData()
|
||||
}, [id])
|
||||
const getData = () => {
|
||||
setLoading(true)
|
||||
getHotMemoryTagsByUser(id as string).then(res => {
|
||||
const response = res as { name: string; frequency: number }[]
|
||||
setData(response.map(item => ({
|
||||
...item,
|
||||
value: item.frequency,
|
||||
})))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading
|
||||
? <Loading size={249} />
|
||||
: !data || data.length === 0
|
||||
? <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
|
||||
: data && data.length > 0 &&
|
||||
<ReactEcharts
|
||||
option={{
|
||||
color: Colors,
|
||||
tooltip: {
|
||||
show: false,
|
||||
trigger: 'item',
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontSize: 12,
|
||||
width: 27,
|
||||
height: 16,
|
||||
},
|
||||
formatter: '{d}%',
|
||||
padding: [8, 5],
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderColor: '#DFE4ED',
|
||||
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
|
||||
},
|
||||
legend: {
|
||||
type: data.length > 8 ? 'scroll' : 'plain',
|
||||
bottom: 0,
|
||||
left: 16,
|
||||
padding: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
borderRadius: 2,
|
||||
// orient: 'horizontal',
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 16,
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Access From',
|
||||
type: 'pie',
|
||||
radius: ['60%', '100%'],
|
||||
avoidLabelOverlap: false,
|
||||
percentPrecision: 0,
|
||||
padAngle: 0,
|
||||
width: 220,
|
||||
height: 220,
|
||||
top: 32,
|
||||
left: 'center',
|
||||
itemStyle: {
|
||||
borderRadius: 0
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#212332',
|
||||
formatter: '{d}%\n{b}',
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}}
|
||||
style={{ height: '340px', width: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
onEvents={{
|
||||
// 图表渲染完成后再次调整大小,确保宽度正确
|
||||
// 使用 setTimeout 避免在主渲染过程中调用 resize
|
||||
rendered: () => {
|
||||
if (chartRef.current) {
|
||||
setTimeout(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PieCard
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { type FC, useEffect, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Col } from 'antd'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import zoom from '@/assets/images/userMemory/zoom.svg'
|
||||
import drag from '@/assets/images/userMemory/drag.svg'
|
||||
import pointer from '@/assets/images/userMemory/pointer.svg'
|
||||
import empty from '@/assets/images/userMemory/empty.svg'
|
||||
import type { EdgeData, Node, Edge } from '../types'
|
||||
import {
|
||||
getMemorySearchEdges,
|
||||
} from '@/api/memory'
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
const operations = [
|
||||
{ name: 'click', icon: pointer },
|
||||
{ name: 'drag', icon: drag },
|
||||
{ name: 'zoom', icon: zoom },
|
||||
]
|
||||
const RelationshipNetwork:FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const chartRef = useRef<ReactEcharts>(null)
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [links, setLinks] = useState<Edge[]>([])
|
||||
const [categories, setCategories] = useState<{ name: string }[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getEdgeData()
|
||||
}, [id])
|
||||
|
||||
// 关系网络
|
||||
const getEdgeData = () => {
|
||||
if (!id) return
|
||||
setSelectedNode(null)
|
||||
getMemorySearchEdges(id).then((res) => {
|
||||
const list = (res as { detials?: EdgeData[] }).detials || []
|
||||
const nodes: Node[] = [];
|
||||
const links: Edge[] = [];
|
||||
const categories: { name: string }[] = []
|
||||
|
||||
list.forEach(item => {
|
||||
if (item.edge) {
|
||||
links.push({
|
||||
...item.edge,
|
||||
target: item.edge?.target_id,
|
||||
source: item.edge?.source_id,
|
||||
})
|
||||
}
|
||||
if (item.sourceNode) {
|
||||
nodes.push(item.sourceNode)
|
||||
categories.push({name: item.sourceNode.entity_type})
|
||||
}
|
||||
if (item.targetNode) {
|
||||
nodes.push(item.targetNode)
|
||||
categories.push({name: item.targetNode.entity_type})
|
||||
}
|
||||
})
|
||||
|
||||
// 根据ID字段去重节点
|
||||
const uniqueNodes = nodes.filter((node, index, self) =>
|
||||
index === self.findIndex((n) => n.id === node.id && n.name === node.name)
|
||||
)
|
||||
const uniqueLinks = links.filter((node, index, self) =>
|
||||
index === self.findIndex((n) => n.target === node.target && n.source === node.source)
|
||||
)
|
||||
const uniqueCategories = categories.filter((node, index, self) =>
|
||||
index === self.findIndex((n) => n.name === node.name)
|
||||
)
|
||||
|
||||
setLinks(uniqueLinks)
|
||||
setCategories(uniqueCategories)
|
||||
|
||||
uniqueNodes.map(item => {
|
||||
const index = uniqueCategories.findIndex((n) => n.name === item.entity_type)
|
||||
item.category = index
|
||||
item.symbolSize = index < 10 ? 5 : index <100 ? 8 : 10
|
||||
})
|
||||
setNodes(uniqueNodes)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* 关系网络 */}
|
||||
<Col span={24}>
|
||||
<RbCard
|
||||
title={t('userMemory.relationshipNetwork')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
>
|
||||
<div className="rb:h-[496px]">
|
||||
{nodes.length === 0 ? (
|
||||
<Empty className="rb:h-full" />
|
||||
) : (
|
||||
<ReactEcharts
|
||||
option={{
|
||||
colors: ['#155EEF', '4DA8FF', '#9C6FFF', '#8BAEF7', '#369F21', '#FF5D34', '#FF8A4C', '#FFB048'],
|
||||
tooltip: {
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
data: nodes || [],
|
||||
links: links || [],
|
||||
categories: categories || [],
|
||||
roam: true,
|
||||
lineStyle: {
|
||||
color: 'source',
|
||||
curveness: 0.3
|
||||
},
|
||||
force: {
|
||||
repulsion: 100,
|
||||
// 启用类别聚合
|
||||
edgeLength: 80,
|
||||
gravity: 0.3,
|
||||
// 同类别的节点相互吸引
|
||||
layoutAnimation: true,
|
||||
// 防止点击时重新计算布局
|
||||
preventOverlap: true,
|
||||
// 点击节点后保持布局稳定
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: [4, 10],
|
||||
// 初始布局完成后关闭力导向
|
||||
initLayout: 'force'
|
||||
},
|
||||
selectedMode: 'single',
|
||||
draggable: true,
|
||||
// 防止数据更新时重新计算布局
|
||||
animationDurationUpdate: 0,
|
||||
select: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
shadowBlur: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
style={{ height: '496px', width: '100%' }}
|
||||
notMerge={false}
|
||||
lazyUpdate={true}
|
||||
onEvents={{
|
||||
// 图表渲染完成后再次调整大小,确保宽度正确
|
||||
// 使用 setTimeout 避免在主渲染过程中调用 resize
|
||||
rendered: () => {
|
||||
if (chartRef.current) {
|
||||
setTimeout(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
// 节点点击事件处理
|
||||
click: (params: { dataType: string; data: Node }) => {
|
||||
if (params.dataType === 'node') {
|
||||
// 处理节点点击事件
|
||||
console.log('Node clicked:', params.data);
|
||||
setSelectedNode(params.data)
|
||||
if (selectedNode?.id === params.data.id) {
|
||||
setSelectedNode(null)
|
||||
} else {
|
||||
setSelectedNode(params.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:bg-[#F0F3F8] rb:flex rb:items-center rb:gap-[24px] rb:rounded-[0px_0px_12px_12px] rb:p-[14px_40px] rb:m-[0_-20px_-16px_-16px]">
|
||||
{operations.map((item) => (
|
||||
<div key={item.name} className="rb:flex rb:items-center rb:text-[#5B6167] rb:leading-[20px]">
|
||||
<img src={item.icon} className="rb:w-[20px] rb:h-[20px] rb:mr-[4px]" />
|
||||
{t(`userMemory.${item.name}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RbCard>
|
||||
</Col>
|
||||
{/* 记忆详情 */}
|
||||
<Col span={24}>
|
||||
<RbCard
|
||||
title={t('userMemory.memoryDetails')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
>
|
||||
{(!selectedNode || (!selectedNode?.description && !selectedNode?.entity_type))
|
||||
? <Empty
|
||||
url={empty}
|
||||
title={t('userMemory.memoryDetailEmpty')}
|
||||
subTitle={t('userMemory.memoryDetailEmptyDesc')}
|
||||
className="rb:mb-[12px]"
|
||||
size={88}
|
||||
/>
|
||||
: <>
|
||||
{selectedNode?.description &&
|
||||
<div className="rb:font-medium rb:mb-[8px]">
|
||||
{t('userMemory.description')}
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]"> {selectedNode.description}</div>
|
||||
</div>
|
||||
}
|
||||
{selectedNode?.entity_type &&
|
||||
<div className="rb:font-medium rb:mb-[8px]">
|
||||
{t('userMemory.entityType')}
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]"> {selectedNode.entity_type}</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</RbCard>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 使用React.memo包装组件,避免不必要的渲染
|
||||
export default React.memo(RelationshipNetwork)
|
||||
17
web/src/views/UserMemoryDetail/index.tsx
Normal file
17
web/src/views/UserMemoryDetail/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type FC } from 'react'
|
||||
import { useUser } from '@/store/user'
|
||||
import Neo4j from './Neo4j'
|
||||
import Rag from './Rag'
|
||||
|
||||
const UserMemoryDetail: FC = () => {
|
||||
const { storageType } = useUser()
|
||||
|
||||
if (storageType === 'neo4j') {
|
||||
return <Neo4j />
|
||||
}
|
||||
if (storageType === 'rag') {
|
||||
return <Rag />
|
||||
}
|
||||
return null
|
||||
}
|
||||
export default UserMemoryDetail
|
||||
68
web/src/views/UserMemoryDetail/types.ts
Normal file
68
web/src/views/UserMemoryDetail/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface Data {
|
||||
id: string | number
|
||||
name: string;
|
||||
type: string;
|
||||
source: string;
|
||||
createTime: string;
|
||||
icon?: string;
|
||||
memoryInsight?: string;
|
||||
recentMemories?: {
|
||||
title: string;
|
||||
time: string;
|
||||
position: string;
|
||||
tags: string[];
|
||||
}[];
|
||||
roles?: string[];
|
||||
tags?: string[];
|
||||
username: string;
|
||||
totalNumOfMemories: number;
|
||||
footprintCity: number;
|
||||
totalNumOfPhotos: string;
|
||||
importantRelationships: number;
|
||||
aboutUs?: {
|
||||
content: string;
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
relationships?: {
|
||||
name: string[];
|
||||
relation: string;
|
||||
memories: number;
|
||||
}[];
|
||||
importantMoments?: {
|
||||
title: string;
|
||||
time: string;
|
||||
desc: string;
|
||||
}[];
|
||||
interestDistribution?: {
|
||||
value: number;
|
||||
name: string;
|
||||
}[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
connect_strength?: string;
|
||||
entity_idx: number;
|
||||
entity_type?: string;
|
||||
fact_summary?: string[];
|
||||
category?: number;
|
||||
symbolSize?: number;
|
||||
}
|
||||
export interface Edge {
|
||||
statement: string;
|
||||
rel_id: string;
|
||||
source_id: string;
|
||||
predicate: string;
|
||||
target_id: string;
|
||||
statement_id: string;
|
||||
target?: string;
|
||||
source?: string;
|
||||
}
|
||||
export interface EdgeData {
|
||||
sourceNode: Node;
|
||||
edge: Edge;
|
||||
targetNode: Node;
|
||||
}
|
||||
Reference in New Issue
Block a user