feat(web): neo4j type user memory detail

This commit is contained in:
zhaoying
2025-12-26 19:14:26 +08:00
parent 7dd4db52df
commit e11c1bb233
23 changed files with 516 additions and 280 deletions

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>全屏</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="红熊空间-记忆管理-个人记忆" transform="translate(-967, -349)" stroke="#5B6167">
<g id="图谱" transform="translate(432, 334)">
<g id="编组-18" transform="translate(535, 13)">
<g id="全屏" transform="translate(0, 2)">
<g id="编组-6" transform="translate(2, 2)">
<polyline id="路径" points="0 3 0 0 3 0"></polyline>
<polyline id="路径" transform="translate(10.5, 1.5) scale(-1, 1) translate(-10.5, -1.5)" points="9 3 9 0 12 0"></polyline>
<polyline id="路径" transform="translate(1.5, 10.5) scale(1, -1) translate(-1.5, -10.5)" points="0 12 0 9 3 9"></polyline>
<polyline id="路径" transform="translate(10.5, 10.5) scale(-1, -1) translate(-10.5, -10.5)" points="9 12 9 9 12 9"></polyline>
<line x1="5.30274338e-05" y1="0" x2="4.00960414" y2="4.02323899" id="路径-3"></line>
<line x1="12" y1="0" x2="8.01466597" y2="4.01783837" id="路径-4"></line>
<line x1="0" y1="11.9995998" x2="4.00871245" y2="8.01746225" id="路径-5"></line>
<line x1="11.9977093" y1="12" x2="8.01643138" y2="8.01746225" id="路径-7"></line>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>全屏</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="红熊空间-记忆管理-个人记忆" transform="translate(-967, -349)" stroke="#212332">
<g id="图谱" transform="translate(432, 334)">
<g id="编组-18" transform="translate(535, 13)">
<g id="全屏" transform="translate(0, 2)">
<g id="编组-6" transform="translate(2, 2)">
<polyline id="路径" points="0 3 0 0 3 0"></polyline>
<polyline id="路径" transform="translate(10.5, 1.5) scale(-1, 1) translate(-10.5, -1.5)" points="9 3 9 0 12 0"></polyline>
<polyline id="路径" transform="translate(1.5, 10.5) scale(1, -1) translate(-1.5, -10.5)" points="0 12 0 9 3 9"></polyline>
<polyline id="路径" transform="translate(10.5, 10.5) scale(-1, -1) translate(-10.5, -10.5)" points="9 12 9 9 12 9"></polyline>
<line x1="5.30274338e-05" y1="0" x2="4.00960414" y2="4.02323899" id="路径-3"></line>
<line x1="12" y1="0" x2="8.01466597" y2="4.01783837" id="路径-4"></line>
<line x1="0" y1="11.9995998" x2="4.00871245" y2="8.01746225" id="路径-5"></line>
<line x1="11.9977093" y1="12" x2="8.01643138" y2="8.01746225" id="路径-7"></line>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -2,7 +2,7 @@
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>刷新</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="应用管理--API" transform="translate(-1071, -490)" fill="#212332" fill-rule="nonzero">
<g id="应用管理--API" transform="translate(-1071, -490)" fill="#5B6167" fill-rule="nonzero">
<g id="2" transform="translate(220, 336)">
<g id="主操作" transform="translate(839, 144)">
<g id="刷新" transform="translate(12, 10)">

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>刷新</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="应用管理--API" transform="translate(-1071, -490)" fill="#155EEF" fill-rule="nonzero">
<g id="2" transform="translate(220, 336)">
<g id="主操作" transform="translate(839, 144)">
<g id="刷新" transform="translate(12, 10)">
<path d="M14.5,6.60760714 L14.5,3.35760714 L13.4384813,4.41757143 C12.2346397,2.59629629 10.195406,1.50029795 8.00999678,1.5 C4.41487535,1.5 1.5,4.41014286 1.5,8.00046429 C1.5,11.5907857 4.41487535,14.5009287 8.00999678,14.5009287 C10.6602392,14.5014558 13.046297,12.8977788 14.0434028,10.4458571 C14.1184045,10.261489 14.0892053,10.0511714 13.9668045,9.89412914 C13.8444036,9.73708685 13.6473966,9.65717825 13.4499941,9.68450414 C13.2525917,9.71183003 13.0847838,9.84223896 13.0097822,10.0266071 C12.1832078,12.0581943 10.2060581,13.38691 8.00999678,13.3866429 C5.03095604,13.3866429 2.61591974,10.9751429 2.61591974,8.00046429 C2.61591974,5.02578571 5.03095604,2.61428571 8.00999678,2.61428571 C9.93449337,2.61428571 11.6706785,3.63107143 12.6308344,5.22357143 L11.2452341,6.60760714 L14.5,6.60760714 Z" id="路径"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -13,7 +13,7 @@ const { Content } = Layout;
// 认证布局组件使用useRouteGuard hook进行路由鉴权
const AuthLayout: FC = () => {
const { getUserInfo } = useUser();
const { getUserInfo, getStorageType } = useUser();
// 使用路由守卫hook处理认证和权限检查
useRouteGuard('manage');
// 自动更新面包屑导航
@@ -24,6 +24,7 @@ const AuthLayout: FC = () => {
window.location.href = `/#/login`;
} else {
getUserInfo()
getStorageType()
}
}, []);

View File

@@ -4,12 +4,13 @@ import { useUser } from '@/store/user';
// 基础布局组件,用于展示内容并保留用户信息获取功能
const BasicLayout: FC = () => {
const { getUserInfo } = useUser();
const { getUserInfo, getStorageType } = useUser();
// 获取用户信息
useEffect(() => {
getUserInfo();
}, [getUserInfo]);
getStorageType()
}, [getUserInfo, getStorageType]);
return (
<div className="rb:relative rb:h-full rb:w-full">

View File

@@ -329,7 +329,8 @@ export const en = {
publicApiCannotRefreshToken: 'Public API cannot refresh token',
refreshTokenNotExist: 'Refresh token does not exist',
reset: 'Reset',
refresh: 'Refresh'
refresh: 'Refresh',
return: 'Return',
},
model: {
searchPlaceholder: 'search model…',
@@ -1023,7 +1024,7 @@ export const en = {
drag: 'Drag and drop to move nodes',
zoom: 'Scroll zoom view',
memoryDetailEmpty: 'Please select a memory node',
memoryDetailEmptyDesc: 'Click on any node in the above view to view detailed information',
memoryDetailEmptyDesc: 'Click on the node in the left graph to view the details of entity memory',
totalNumOfMemories: 'Total Number of Memories',
footprintCity: 'Footprint City',
@@ -1067,6 +1068,8 @@ export const en = {
hire_date: 'Hire Date',
memoryContent: 'Memory Content',
created_at: 'Created At',
memoryWindow: "{{name}}'s Window of Memory"
},
space: {
createSpace: 'Create Space',

View File

@@ -805,7 +805,8 @@ export const zh = {
publicApiCannotRefreshToken: '公共接口不能刷新token',
refreshTokenNotExist: '刷新token不存在',
reset: '重置',
refresh: '刷新'
refresh: '刷新',
return: '返回',
},
product: {
applicationManagement: '应用管理',
@@ -1105,7 +1106,7 @@ export const zh = {
drag: '拖放移动节点',
zoom: '滚动缩放视图',
memoryDetailEmpty: '请选择一个记忆节点',
memoryDetailEmptyDesc: '点击上方视图中的任何节点查看详细信息',
memoryDetailEmptyDesc: '点击左侧图表中的节点查看实体记忆详情',
totalNumOfMemories: '记忆总数',
footprintCity: '足迹城市',
@@ -1151,6 +1152,8 @@ export const zh = {
hire_date: '入职时间',
memoryContent: '记忆内容',
created_at: '创建时间',
updated_at: '最后更新时间',
fullScreen: '全屏'
},
space: {
createSpace: '创建空间',
@@ -1476,7 +1479,7 @@ export const zh = {
auth: '认证',
requestHeader: '请求头',
config: '配置',
auth_Type: '认证方式',
auth_type: '认证方式',
none: '无需认证',
api_key: 'API Key',
basic_auth: 'Basic Auth',

View File

@@ -38,6 +38,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
Home: lazy(() => import('@/views/Home')),
UserMemory: lazy(() => import('@/views/UserMemory')),
UserMemoryDetail: lazy(() => import('@/views/UserMemoryDetail')),
Neo4jUserMemoryDetail: lazy(() => import('@/views/UserMemoryDetail/Neo4j')),
MemberManagement: lazy(() => import('@/views/MemberManagement')),
MemoryManagement: lazy(() => import('@/views/MemoryManagement')),
ForgettingEngine: lazy(() => import('@/views/ForgettingEngine')),

View File

@@ -41,7 +41,8 @@
"element": "BasicLayout",
"children": [
{ "path": "/application/config/:id", "element": "ApplicationConfig" },
{ "path": "/conversation/:token", "element": "Conversation" }
{ "path": "/conversation/:token", "element": "Conversation" },
{ "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" }
]
},
{

View File

@@ -284,7 +284,7 @@
"code": "userMemoryDetail",
"label": "记忆详情",
"i18nKey": "menu.userMemoryDetail",
"path": "/user-memory/:id",
"path": "/user-memory/neo4j/:id",
"enable": true,
"display": false,
"level": 2,
@@ -304,6 +304,18 @@
"subs": null
}
]
},
{
"id": 81,
"parent": 8,
"code": "userMemoryDetail",
"label": "记忆详情",
"i18nKey": "menu.userMemoryDetail",
"path": "/user-memory/:id",
"enable": true,
"display": false,
"level": 2,
"sort": 0
}
]
},

View File

@@ -13,6 +13,7 @@ import onlineNum from '@/assets/images/memory/onlineNum.svg'
import Table from '@/components/Table'
import { getTotalEndUsers, userMemoryListUrl, getUserMemoryList } from '@/api/memory';
import ConfigModal from './components/ConfigModal';
import { useUser } from '@/store/user'
const bgList = [
'linear-gradient( 180deg, #F1F6FE 0%, #FBFDFF 100%)',
@@ -31,6 +32,7 @@ const IconList: Record<string, string> = {
export default function UserMemory() {
const { t } = useTranslation();
const navigate = useNavigate()
const { storageType } = useUser()
const configModalRef = useRef<ConfigModalRef>(null)
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Data[]>([]);
@@ -58,8 +60,15 @@ export default function UserMemory() {
setLoading(false)
})
}
console.log('storageType', storageType)
const handleViewDetail = (id: string | number) => {
navigate(`/user-memory/${id}`)
switch (storageType) {
case 'neo4j':
navigate(`/user-memory/neo4j/${id}`)
break;
default:
navigate(`/user-memory/${id}`)
}
}
const handleChangeLayout = (e: RadioChangeEvent) => {
const type = e.target.value

View File

@@ -1,168 +1,87 @@
import { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import clsx from 'clsx'
import { Row, Col, Skeleton, Flex, Button } from 'antd'
import { type FC, useEffect, useRef, useState } from 'react'
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 {
getUserSummary,
analyticsRefresh
} from '@/api/memory'
import type { MemoryInsightRef } from './types'
import { Row, Col, Space, Button } from 'antd'
import { useTranslation } from 'react-i18next';
import PageHeader from './components/PageHeader'
import EndUserProfile from './components/EndUserProfile'
import AboutMe from './components/AboutMe'
import InterestDistribution from './components/InterestDistribution'
import NodeStatistics from './components/NodeStatistics'
import RelationshipNetwork from './components/RelationshipNetwork'
import MemoryInsight from './components/MemoryInsight'
import Empty from '@/components/Empty'
import NodeStatistics from './components/NodeStatistics'
import EndUserProfile from './components/EndUserProfile'
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-4.25 rb:border-b rb:border-[#DFE4ED] rb:text-[16px] rb:font-semibold rb:leading-5.5">
<span className="rb:flex rb:items-center">
<img src={icon} className="rb:w-5 rb:h-5 rb:mr-2" />
{title}
</span>
<span className="rb:flex rb:items-center rb:cursor-pointer rb:text-[#5B6167] rb:text-[14px] rb:font-regular rb:leading-5" onClick={() => onClick(type)}>
{t(`userMemory.${expanded ? 'foldUp' : 'expanded'}`)}
<img src={down} className={clsx("rb:w-4 rb:h-4 rb:ml-1", {
'rb:rotate-180': !expanded,
})} />
</span>
</div>
)
import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef } from './types'
import {
analyticsRefresh,
} from '@/api/memory'
const Neo4j: FC = () => {
const { t } = useTranslation()
const { t } = useTranslation();
const { id } = useParams()
const [loading, setLoading] = useState(false)
const [name, setName] = useState('')
const ref = useRef<EndUserProfileRef>(null)
const memoryInsightRef = useRef<MemoryInsightRef>(null)
const [expanded, setExpanded] = useState<string[]>(['aboutUs', 'interestDistribution', 'importantRelationships', 'importantMomentsInLife'])
const [summary, setSummary] = useState<string | null>(null)
const [loading, setLoading] = useState<Record<string, boolean>>({
summary: false,
refresh: false
})
const aboutMeRef = useRef<AboutMeRef>(null)
useEffect(() => {
if (!id) return
getSummary()
}, [id])
const handleNameUpdate = (data: { other_name?: string; id: string }) => {
setName(data.other_name ?? data.id)
}
const handleTitleClick = (key: string) => {
setExpanded(expanded.includes(key) ? expanded.filter((item) => item !== key) : [...expanded, key])
}
// 用户摘要
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 handleRefresh = () => {
setLoading(prev => ({ ...prev, refresh: true }))
setLoading(true)
analyticsRefresh(id as string)
.then(res => {
const response = res as { insight_success: boolean; summary_success: boolean; }
if (response.insight_success) {
memoryInsightRef.current?.getInsightReport()
memoryInsightRef.current?.getData()
}
if (response.summary_success) {
getSummary()
memoryInsightRef.current?.getData()
}
})
.finally(() => {
setLoading(prev => ({ ...prev, refresh: false }))
setLoading(false)
})
}
return (
<div>
<Flex justify="flex-end">
<Button type="primary" loading={loading.refresh} className="rb:mb-3" onClick={handleRefresh}>
{t('common.refresh')}
</Button>
</Flex>
<Row gutter={[16, 16]} className="rb:pb-6">
<Col span={8}>
<Row gutter={[16, 16]}>
<Col span={24}>
<EndUserProfile />
</Col>
<Col span={24}>
<RbCard>
{/* 关于我 */}
<>
<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-4" />
: summary
? <div className="rb:font-regular rb:leading-5.5 rb:pt-4">
{summary || '-'}
</div>
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</>
)}
</>
{/* 兴趣分布 */}
<>
<Title
type="interestDistribution"
title={t('userMemory.interestDistribution')}
icon={interestDistribution}
t={t}
expanded={expanded.includes('interestDistribution')}
onClick={handleTitleClick}
/>
{expanded.includes('interestDistribution') && (
<PieCard />
)}
</>
</RbCard>
</Col>
</Row>
</Col>
<Col span={16}>
<Row gutter={[16, 16]}>
<Col span={24}>
<div className="rb:h-full rb:w-full">
<PageHeader
name={name}
operation={(
<Button
loading={loading}
className="rb:group rb:h-7! rb:bg-transparent! rb:border-[#5B6167] rb:text-[#5B6167] rb:ml-3"
onClick={handleRefresh}
>
{!loading && <div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh.svg')] rb:group-hover:bg-[url('@/assets/images/refresh_hover.svg')]"
></div>}
{t('common.refresh')}
</Button>
)}
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
<Row gutter={16}>
<Col span={8}>
<Space size={16} direction="vertical" className="rb:w-full">
<EndUserProfile ref={ref} onDataLoaded={handleNameUpdate} />
<AboutMe ref={aboutMeRef} />
<InterestDistribution />
</Space>
</Col>
<Col span={16}>
<Space size={16} direction="vertical" className="rb:w-full">
<NodeStatistics />
</Col>
{/* 记忆洞察 */}
<Col span={24}>
<RelationshipNetwork />
<MemoryInsight ref={memoryInsightRef} />
</Col>
{/* 关系网络 + 记忆详情 */}
<RelationshipNetwork />
</Row>
</Col>
</Space>
</Col>
</Row>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { type FC, useEffect, useState, forwardRef, useImperativeHandle } 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 {
getUserSummary,
} from '@/api/memory'
import type { AboutMeRef } from '../types'
const AboutMe = forwardRef<AboutMeRef>((_props, ref) => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<string | null>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
// 记忆洞察
const getData = () => {
if (!id) return
setLoading(true)
getUserSummary(id)
.then((res) => {
setData((res as { summary?: string }).summary || null)
})
.finally(() => {
setLoading(false)
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
getData,
}));
return (
<RbCard
title={t('userMemory.aboutMe')}
>
{loading
? <Skeleton className="rb:mt-4" />
: data
? <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167]">
{data || '-'}
</div>
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</RbCard>
)
})
export default AboutMe

View File

@@ -1,17 +1,21 @@
import { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { forwardRef, useImperativeHandle, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Descriptions, Button } from 'antd';
import { Skeleton } from 'antd';
import dayjs from 'dayjs'
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import {
getEndUserProfile,
} from '@/api/memory'
import EndUserProfileModal from './EndUserProfileModal'
import type { EndUser, EndUserProfileModalRef } from '../types'
import type { EndUser, EndUserProfileModalRef, EndUserProfileRef } from '../types'
const EndUserProfile:FC = () => {
interface EndUserProfileProps {
onDataLoaded?: (data: { other_name?: string; id: string }) => void
}
const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ onDataLoaded }, ref) => {
const { t } = useTranslation()
const { id } = useParams()
const endUserProfileModalRef = useRef<EndUserProfileModalRef>(null)
@@ -28,7 +32,12 @@ const EndUserProfile:FC = () => {
if (!id) return
setLoading(true)
getEndUserProfile(id).then((res) => {
setData(res as EndUser)
const userData = res as EndUser
setData(userData)
onDataLoaded?.({
other_name: userData.other_name,
id: userData.id
})
setLoading(false)
})
.finally(() => {
@@ -36,31 +45,45 @@ const EndUserProfile:FC = () => {
})
}
const formatItems = useCallback(() => {
if (!data) return []
return ['other_name', 'position', 'department', 'contact', 'phone', 'hire_date'].map(key => ({
key,
label: t(`userMemory.${key}`),
children: key === 'hire_date' && data[key] ? dayjs(data[key as keyof EndUser]).format('YYYY-MM-DD') : String(data[key as keyof EndUser] || ''),
children: key === 'hire_date' && data?.[key] ? dayjs(data[key as keyof EndUser]).format('YYYY-MM-DD') : String(data?.[key as keyof EndUser] || '-'),
}))
}, [data])
const handleEdit = () => {
if (!data) return
endUserProfileModalRef.current?.handleOpen(data)
}
useImperativeHandle(ref, () => ({
data
}));
return (
<RbCard
title={t('userMemory.endUserProfile')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
extra={
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
onClick={handleEdit}
></div>
}
>
{loading
? <Skeleton />
: data
? <div className="rb:flex rb:flex-wrap rb:justify-between rb:h-full">
<Descriptions column={1} items={formatItems()} classNames={{ label: 'rb:w-24' }} />
<Button className="rb:mt-3" block onClick={handleEdit}>{t('common.edit')}</Button>
: <div className="rb:flex rb:flex-col rb:justify-between rb:gap-3 rb:h-full">
{formatItems().map(vo => (
<div key={vo.key} className="rb:flex rb:justify-between rb:items-center rb:gap-3 rb:leading-5">
<div className="rb:text-[#5B6167]">{vo.label}</div>
<div className="">{vo.children}</div>
</div>
))}
<div className="rb:border-t rb:border-t-[#DFE4ED] rb:pt-4 rb:text-[#5B6167] rb:text-[12px] rb:leading-4">
{t('userMemory.updated_at')}: {data?.updatetime_profile ? dayjs(data?.updatetime_profile).format('YYYY/MM/DD HH:mm:ss') : ''}
</div>
</div>
: <Empty size={80} />
}
<EndUserProfileModal
ref={endUserProfileModalRef}
@@ -68,5 +91,5 @@ const EndUserProfile:FC = () => {
/>
</RbCard>
)
}
})
export default EndUserProfile

View File

@@ -1,18 +1,24 @@
import { type FC, useRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import ReactEcharts from 'echarts-for-react';
import { Space } from 'antd'
import { getHotMemoryTagsByUser } from '@/api/memory';
import Empty from '@/components/Empty';
import Loading from '@/components/Empty/Loading';
import RbCard from '@/components/RbCard/Card';
const Colors = ['#155EEF', '#4DA8FF', '#03BDFF', '#31E8FF', '#AD88FF', '#FFB048']
const PieCard: FC = () => {
const InterestDistribution: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const chartRef = useRef<ReactEcharts>(null);
const resizeScheduledRef = useRef(false)
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Array<Record<string, string | number>>>([])
const totalValue = data.reduce((sum, item) => sum + Number(item.value), 0)
useEffect(() => {
getData()
@@ -55,12 +61,14 @@ const PieCard: FC = () => {
}, [data])
return (
<>
<RbCard
title={t('userMemory.interestDistribution')}
>
{loading
? <Loading size={249} />
: !data || data.length === 0
? <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
: data && data.length > 0 &&
: data && data.length > 0 && <>
<ReactEcharts
option={{
color: Colors,
@@ -80,19 +88,7 @@ const PieCard: FC = () => {
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,
}
show: false
},
series: [
{
@@ -102,9 +98,9 @@ const PieCard: FC = () => {
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 0,
width: 220,
height: 220,
top: 32,
width: 200,
height: 200,
top: 18,
left: 'center',
itemStyle: {
borderRadius: 0
@@ -129,13 +125,27 @@ const PieCard: FC = () => {
}
]
}}
style={{ height: '340px', width: '100%' }}
style={{ height: '250px', width: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
}
</>
<Space size={12} direction="vertical" className="rb:w-full">
{data.map((item, index) => (
<div key={index} className="rb:relative rb:flex rb:items-center rb:justify-between rb:px-4 rb:py-2.5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:font-regular rb:leading-5 rb:rounded-md">
<div className="rb:pl-3.5 rb:relative">
<span
className="rb:absolute rb:left-0 rb:top-[calc(50%-4px)] rb:w-2 rb:h-2 rb:rounded-full"
style={{ backgroundColor: Colors[index % Colors.length] }}
/>
{item.name}
</div>
<div className="rb:font-medium">{totalValue > 0 ? Math.round((Number(item.value) / totalValue) * 100) : 0}%</div>
</div>
))}
</Space>
</>}
</RbCard>
)
}
export default PieCard
export default InterestDistribution

View File

@@ -17,11 +17,11 @@ const MemoryInsight = forwardRef<MemoryInsightRef>((_props, ref) => {
useEffect(() => {
if (!id) return
getInsightReport()
getData()
}, [id])
// 记忆洞察
const getInsightReport = () => {
const getData = () => {
if (!id) return
setLoading(true)
getMemoryInsightReport(id).then((res) => {
@@ -34,23 +34,18 @@ const MemoryInsight = forwardRef<MemoryInsightRef>((_props, ref) => {
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
getInsightReport,
getData,
}));
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-5.5">
{report|| '-'}
</div>
? <div className="rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:py-3 rb:px-4 rb:text-[#5B6167] rb:leading-5">
{report || '-'}
</div>
: <Empty size={80} />
}

View File

@@ -0,0 +1,88 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Skeleton } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import {
getNodeStatistics,
} from '@/api/memory'
import type { NodeStatisticsItem } from '../types'
const NodeStatistics: FC = () => {
const navigate = useNavigate();
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [total, setTotal] = useState<number>(0)
const [data, setData] = useState<NodeStatisticsItem[]>([])
useEffect(() => {
if (!id) return
getData()
}, [id])
// 记忆洞察
const getData = () => {
if (!id) return
setLoading(true)
getNodeStatistics(id).then((res) => {
const response = res as NodeStatisticsItem[]
setData(response)
// 计算count总计
const totalCount = response.reduce((sum, item) => sum + (item.count || 0), 0)
setTotal(totalCount)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
const handleViewDetail = (type: string) => {
switch (type) {
case 'EMOTIONAL_MEMORY':
navigate(`/statement/${id}`)
break
}
}
return (
<RbCard
title={<>{t('userMemory.nodeStatistics')}<div>{t('userMemory.total')}: {total}</div></>}
headerType="borderless"
>
{loading
? <Skeleton />
: data && data.length > 0
? <div className={`rb:w-full rb:grid rb:grid-cols-3 rb:gap-2`}>
{data.map(vo => (
<div
key={vo.type}
className={clsx("rb:group rb:border rb:border-[#DFE4ED] rb:p-0 rb:rounded-xl rb:hover:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)]", {
'rb:cursor-pointer': vo.type === 'EMOTIONAL_MEMORY'
})}
onClick={() => handleViewDetail(vo.type)}
>
<div className="rb:gap-0.5 rb:p-3 rb:leading-4 rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border-b rb:border-[#DFE4ED]">
<div className="rb:wrap-break-word rb:line-clamp-1">{t(`userMemory.${vo.type}`)}</div>
{vo.type === 'EMOTIONAL_MEMORY' && <div
className="rb:w-3 rb:h-3 rb:-ml-0.75 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/home/arrow_top_right.svg')] rb:group-hover:bg-[url('@/assets/images/home/arrow_top_right_hover.svg')]"
></div>}
</div>
<div className="rb:p-3 rb:flex rb:justify-between rb:items-center rb:font-bold rb:text-[20px] rb:text-[#212332] rb:text-left">
{vo.count ?? 0}
<div className="rb:text-right rb:font-normal rb:text-[14px] rb:text-[#5F6266] rb:leading-4 rb:gap-1">
{vo.percentage ?? 0}%
</div>
</div>
</div>
))}
</div>
: <Empty size={80} />
}
</RbCard>
)
}
export default NodeStatistics

View File

@@ -11,6 +11,17 @@ import {
import type { NodeStatisticsItem } from '../types'
const BG_LIST = [
'rb:bg-[linear-gradient(316deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(316deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(314deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(314deg,rgba(255,93,52,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[]',
]
const NodeStatistics: FC = () => {
const navigate = useNavigate();
const { t } = useTranslation()
@@ -49,40 +60,32 @@ const NodeStatistics: FC = () => {
}
return (
<RbCard
title={t('userMemory.nodeStatistics')}
extra={<div>{t('userMemory.total')}: {total}</div>}
title={<>{t('userMemory.nodeStatistics')} <span className="rb:text-[#5B6167] rb:font-normal!">({t('userMemory.total')}: {total})</span></>}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bgColor="linear-gradient(180deg,#F1F9FE 0%, #FBFCFF 100%)"
height="100%"
>
{loading
? <Skeleton />
: data && data.length > 0
? <div className={`rb:w-full rb:grid rb:grid-cols-3 rb:gap-2`}>
{data.map(vo => (
? <div className={`rb:w-full rb:grid rb:grid-cols-8 rb:gap-3`}>
{data.map((vo, index) => (
<div
key={vo.type}
className={clsx("rb:group rb:border rb:border-[#DFE4ED] rb:p-0 rb:rounded-xl rb:hover:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)]", {
className={clsx("rb:flex rb:flex-col rb:justify-between rb:group rb:border rb:border-[#DFE4ED] rb:h-45 rb:rounded-lg rb:pt-3 rb:px-4 rb:pb-5", {
'rb:cursor-pointer': vo.type === 'EMOTIONAL_MEMORY'
})}
}, BG_LIST[index])}
onClick={() => handleViewDetail(vo.type)}
>
<div className="rb:gap-0.5 rb:p-3 rb:leading-4 rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border-b rb:border-[#DFE4ED]">
<div className="rb:wrap-break-word rb:line-clamp-1">{t(`userMemory.${vo.type}`)}</div>
<div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">
{t(`userMemory.${vo.type}`)}
</div>
{vo.type === 'EMOTIONAL_MEMORY' && <div
className="rb:w-3 rb:h-3 rb:-ml-0.75 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/home/arrow_top_right.svg')] rb:group-hover:bg-[url('@/assets/images/home/arrow_top_right_hover.svg')]"
></div>}
</div>
<div className="rb:p-3 rb:flex rb:justify-between rb:items-center rb:font-bold rb:text-[20px] rb:text-[#212332] rb:text-left">
{vo.count ?? 0}
<div className="rb:text-right rb:font-normal rb:text-[14px] rb:text-[#5F6266] rb:leading-4 rb:gap-1">
{vo.percentage ?? 0}%
</div>
</div>
<div className="rb:text-[28px] rb:leading-8.75 rb:font-extrabold">{vo.count ?? 0}</div>
</div>
))}
))}
</div>
: <Empty size={80} />
}

View File

@@ -0,0 +1,38 @@
import { type FC, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from 'antd';
import { useTranslation } from 'react-i18next';
import logoutIcon from '@/assets/images/logout.svg'
const { Header } = Layout;
interface ConfigHeaderProps {
name?: string;
operation: ReactNode
}
const PageHeader: FC<ConfigHeaderProps> = ({
name,
operation
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = () => {
navigate('/user-memory', { replace: true })
}
return (
<Header className="rb:w-full rb:h-16 rb:flex rb:justify-between rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
<div className="rb:h-8 rb:flex rb:items-center rb:font-medium">
{t('userMemory.memoryWindow', { name: name })}
{operation}
</div>
<div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goBack}>
<img src={logoutIcon} className="rb:mr-2 rb:w-4 rb:h-4" />
{t('common.return')}
</div>
</Header>
);
};
export default PageHeader;

View File

@@ -1,25 +1,18 @@
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Col } from 'antd'
import { Col, Row } from 'antd'
import dayjs from 'dayjs'
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 detailEmpty from '@/assets/images/userMemory/detail_empty.png'
import type { Node, Edge, GraphData } from '../types'
import {
getMemorySearchEdges,
} from '@/api/memory'
import Empty from '@/components/Empty'
import dayjs from 'dayjs'
const operations = [
{ name: 'click', icon: pointer },
{ name: 'drag', icon: drag },
{ name: 'zoom', icon: zoom },
]
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
const RelationshipNetwork:FC = () => {
const { t } = useTranslation()
@@ -30,6 +23,7 @@ const RelationshipNetwork:FC = () => {
const [links, setLinks] = useState<Edge[]>([])
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// const [fullScreen, setFullScreen] = useState<boolean>(false)
console.log('categories', categories)
// 关系网络
@@ -136,16 +130,30 @@ const RelationshipNetwork:FC = () => {
}
}, [nodes])
// const handleFullScreen = () => {
// setFullScreen(prev => !prev)
// }
console.log('selectedNode', selectedNode)
return (
<>
<Row gutter={16}>
{/* 关系网络 */}
<Col span={24}>
<Col span={16}>
<RbCard
title={t('userMemory.relationshipNetwork')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
// extra={
// <div
// onClick={handleFullScreen}
// className="rb:group rb:cursor-pointer rb:hover:text-[#212332] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:flex rb:items-center rb:gap-1"
// >
// <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/fullScreen.svg')] rb:hover:bg-[url('@/assets/images/fullScreen_hover.svg')]"></div>
// {t('userMemory.fullScreen')}
// </div>
// }
>
<div className="rb:h-124">
<div className="rb:h-129.5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-sm">
{nodes.length === 0 ? (
<Empty className="rb:h-full" />
) : (
@@ -157,7 +165,7 @@ const RelationshipNetwork:FC = () => {
},
legend: {
show: true,
bottom: 20,
bottom: 12,
},
series: [
{
@@ -207,12 +215,12 @@ const RelationshipNetwork:FC = () => {
}
]
}}
style={{ height: '496px', width: '100%' }}
style={{ height: '518px', width: '100%' }}
notMerge={false}
lazyUpdate={true}
onEvents={{
// 节点点击事件处理
click: (params: { dataType: string; data: Node }) => {
click: (params: { dataType: string; data: Node; name: string }) => {
if (params.dataType === 'node') {
// 处理节点点击事件
console.log('Node clicked:', params.data);
@@ -224,57 +232,52 @@ const RelationshipNetwork:FC = () => {
/>
)}
</div>
<div className="rb:bg-[#F0F3F8] rb:flex rb:items-center rb:gap-6 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-5">
<img src={item.icon} className="rb:w-5 rb:h-5 rb:mr-1" />
{t(`userMemory.${item.name}`)}
</div>
))}
</div>
</RbCard>
</Col>
{/* 记忆详情 */}
<Col span={24}>
<Col span={8}>
<RbCard
title={t('userMemory.memoryDetails')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName='rb:p-0!'
>
{!selectedNode
? <Empty
url={empty}
title={t('userMemory.memoryDetailEmpty')}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:mb-3"
size={88}
/>
: <>
<div className="rb:font-medium rb:mb-2">
{t('userMemory.memoryContent')}
<div className="rb:text-[12px] rb:text-[#5B6167] 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 className="rb:h-133.5">
{!selectedNode
? <Empty
url={detailEmpty}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:h-full rb:mx-10 rb:text-center"
size={90}
/>
: <>
<div className="rb:bg-[#F6F8FC] rb:border-t rb:border-b rb:border-[#DFE4ED] rb:font-medium rb:py-2 rb:px-4 rb:h-10">{selectedNode.name}</div>
<div className="rb:p-4">
<>
<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-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{['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 className="rb:font-medium rb:mb-2 rb:mt-4">
<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-1 rb:pb-4">
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
</div>
</div>
</div>
<div className="rb:font-medium rb:mb-2">
{t('userMemory.created_at')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2">
{dayjs(selectedNode?.properties.created_at).format('YYYY/MM/DD HH:mm:ss')}
</div>
</div>
</>
}
</>
}
</div>
</RbCard>
</Col>
</>
</Row>
)
}
// 使用React.memo包装组件避免不必要的渲染

View File

@@ -121,16 +121,23 @@ export interface NodeStatisticsItem {
export interface EndUser {
end_user_id: string;
id: string;
name: string;
other_name: string;
position: string;
department: string;
contact: string;
phone: string;
hire_date: string | number | Dayjs | null;
updatetime_profile?: number;
}
export interface EndUserProfileModalRef {
handleOpen: (vo: EndUser) => void;
}
export interface MemoryInsightRef {
getInsightReport: () => void
getData: () => void
}
export interface AboutMeRef {
getData: () => void
}
export interface EndUserProfileRef {
data: EndUser | null
}