Merge branch 'feature/20251219_zy' into develop_web

This commit is contained in:
zhaoying
2025-12-22 20:04:53 +08:00
32 changed files with 724 additions and 380 deletions

View File

@@ -15,6 +15,7 @@ import type {
ConfigForm as SelfReflectionEngineConfig
} from '@/views/SelfReflectionEngine/types'
import type { TestParams } from '@/views/MemoryConversation'
import type { EndUser } from '@/views/UserMemoryDetail/types'
import { handleSSE, type SSEMessage } from '@/utils/stream'
// 记忆对话
@@ -66,6 +67,7 @@ export const getTotalEndUsers = () => {
export const getUserProfile = (end_user_id: string) => {
return request.get(`/memory/analytics/user_profile`, { end_user_id })
}
// 用户记忆-记忆洞察
export const getMemoryInsightReport = (end_user_id: string) => {
return request.get(`/memory-storage/analytics/memory_insight/report`, { end_user_id })
@@ -74,9 +76,20 @@ export const getMemoryInsightReport = (end_user_id: string) => {
export const getUserSummary = (end_user_id: string) => {
return request.get(`/memory-storage/analytics/user_summary`, { end_user_id })
}
// 记忆分类
export const getNodeStatistics = (end_user_id: string) => {
return request.get(`/memory-storage/analytics/node_statistics`, { end_user_id })
}
// 基本信息
export const getEndUserProfile = (end_user_id: string) => {
return request.get(`/memory-storage/read_end_user/profile`, { end_user_id })
}
export const updatedEndUserProfile = (values: EndUser) => {
return request.post(`/memory-storage/updated_end_user/profile`, values)
}
// 用户记忆-关系网络
export const getMemorySearchEdges = (end_user_id: string) => {
return request.get(`/memory-storage/search/entity_graph`, { end_user_id })
return request.get(`/memory-storage/analytics/graph_data`, { end_user_id })
}
// 用户记忆-用户兴趣分布
export const getHotMemoryTagsByUser = (end_user_id: string) => {

View File

@@ -0,0 +1,16 @@
<?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>编组 16</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆看板" transform="translate(-1372, -1136)" stroke="#212332">
<g id="快速操作" transform="translate(848, 1048)">
<g id="1" transform="translate(296, 68)">
<g id="编组-16" transform="translate(228, 20)">
<line x1="13.7142857" y1="2.28571429" x2="2.28571429" y2="13.7142857" id="路径-15"></line>
<polyline id="路径" points="5.55102041 2.28571429 13.7142857 2.28571429 13.7142857 10.4489796"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 929 B

View File

@@ -27,7 +27,7 @@ const Empty: FC<EmptyProps> = ({
<div className={`rb:flex rb:items-center rb:justify-center rb:flex-col ${className}`}>
<img src={url || emptyIcon} alt="404" style={{ width: `${width}px`, height: `${height}px` }} />
{title && <div className="rb:mt-2 rb:leading-5">{title}</div>}
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#A8A9AA]`}>{subTitle}</div>}
{curSubTitle && <div className={`rb:mt-[${url ? 8 : 5}px] rb:leading-4 rb:text-[12px] rb:text-[#A8A9AA]`}>{curSubTitle}</div>}
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { type FC, useRef } from 'react';
import { type FC, useCallback, useRef } from 'react';
import { Layout, Dropdown, Space, Breadcrumb } from 'antd';
import type { MenuProps, BreadcrumbProps } from 'antd';
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { useUser } from '@/store/user';
import { useMenu } from '@/store/menu';
import styles from './index.module.css'
@@ -13,7 +13,8 @@ const { Header } = Layout;
const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
const { t } = useTranslation();
const location = useLocation();
const params = useParams();
const navigate = useNavigate();
const settingModalRef = useRef<SettingModalRef>(null)
const userInfoModalRef = useRef<UserInfoModalRef>(null)
@@ -54,7 +55,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
key: '1',
label: (<>
<div>{user.username}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]">{user.email}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2">{user.email}</div>
</>),
},
{
@@ -89,7 +90,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
onClick: handleLogout,
},
];
const formatBreadcrumbNames = () => {
const formatBreadcrumbNames = useCallback(() => {
return breadcrumbs.map((menu, index) => {
const item: any = {
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
@@ -108,13 +109,23 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
};
item.href = '#';
} else if (menu.path && menu.path !== '#') {
// 只有当 path 不是 '#' 时才设置 path
item.path = menu.path;
// 对于三级面包屑的二级菜单,如果路径包含动态参数,替换为当前参数值
if (breadcrumbs.length === 3 && index === 1 && menu.path.includes(':id') && params.id) {
const dynamicPath = menu.path.replace(':id', params.id);
item.onClick = (e: React.MouseEvent) => {
e.preventDefault();
navigate(dynamicPath);
};
item.href = '#';
} else {
// 只有当 path 不是 '#' 时才设置 path
item.path = menu.path;
}
}
return item;
});
}
}, [breadcrumbs, params.id, t, navigate])
return (
<Header className={styles.header}>
<Breadcrumb separator=">" items={formatBreadcrumbNames() as BreadcrumbProps['items']} />

View File

@@ -11,85 +11,47 @@ export const useNavigationBreadcrumbs = (source: 'space' | 'manage' = 'manage')
const menus = allMenus[source] || [];
// 查找匹配的菜单项并构建keyPath
const findMenuKeyPath = (menuList: any[], parentKeys: string[] = []): string[] | null => {
let bestMatch: { path: string; parentId?: string; score: number } | null = null;
const findMenuKeyPath = (menuList: any[]): string[] | null => {
const checkDynamicMatch = (pattern: string, path: string) => {
const pathPattern = pattern.replace(/:[\w-]+/g, '[^/]+');
const regex = new RegExp(`^${pathPattern}$`);
return regex.test(path);
};
for (const menu of menuList) {
// 检查子菜单
if (menu.subs && menu.subs.length > 0) {
const menuPath = menu.path ? (menu.path[0] !== '/' ? '/' + menu.path : menu.path) : '';
for (const sub of menu.subs) {
// 检查三级菜单
if (sub.subs && sub.subs.length > 0) {
for (const subSub of sub.subs) {
if (subSub.path) {
const subSubPath = subSub.path[0] !== '/' ? '/' + subSub.path : subSub.path;
if (subSubPath === currentPath || (subSubPath.includes(':') && checkDynamicMatch(subSubPath, currentPath))) {
return [subSub.path, `${sub.id}`, `${menu.id}`];
}
}
}
}
// 检查二级菜单
if (sub.path) {
const subPath = sub.path[0] !== '/' ? '/' + sub.path : sub.path;
// 精确匹配优先
if (subPath === currentPath) {
if (subPath === currentPath || (subPath.includes(':') && checkDynamicMatch(subPath, currentPath))) {
return [sub.path, `${menu.id}`];
}
console.log('menuPath', menuPath)
// 动态路由匹配
if (subPath.includes(':')) {
// 检查是否在父菜单下
if (menuPath && currentPath.startsWith(menuPath + '/')) {
const relativePath = currentPath.replace(menuPath, '');
const pathSegments = subPath.split('/');
const relativeSegments = relativePath.split('/');
if (pathSegments.length === relativeSegments.length) {
const pathPattern = subPath.replace(/:[\w-]+/g, '[^/]+').replace(/\[[\w-]+\]/g, '[^/]+');
const regex = new RegExp(`^${pathPattern}$`);
if (regex.test(relativePath)) {
return [sub.path, `${menu.id}`];
}
}
}
// 直接匹配子菜单路径
const pathSegments = subPath.split('/');
const currentSegments = currentPath.split('/');
if (pathSegments.length === currentSegments.length) {
const pathPattern = subPath.replace(/:[\w-]+/g, '[^/]+').replace(/\[[\w-]+\]/g, '[^/]+');
const regex = new RegExp(`^${pathPattern}$`);
if (regex.test(currentPath)) {
return [sub.path, `${menu.id}`];
}
}
}
}
}
}
// 检查菜单
// 检查一级菜单
if (menu.path) {
const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path;
// 精确匹配优先
if (menuPath === currentPath) {
return [menu.path, ...parentKeys].reverse();
}
// 动态路由匹配
if (menuPath.includes(':')) {
const pathSegments = menuPath.split('/');
const currentSegments = currentPath.split('/');
if (pathSegments.length === currentSegments.length) {
const pathPattern = menuPath.replace(/:[\w-]+/g, '[^/]+').replace(/\[[\w-]+\]/g, '[^/]+');
const regex = new RegExp(`^${pathPattern}$`);
if (regex.test(currentPath)) {
const score = menuPath.split('/').length;
if (!bestMatch || score > bestMatch.score) {
bestMatch = { path: menu.path, score };
}
}
}
} else if (currentPath.startsWith(menuPath + '/')) {
const score = menuPath.split('/').length;
if (!bestMatch || score > bestMatch.score) {
bestMatch = { path: menu.path, score };
}
if (menuPath === currentPath || (menuPath.includes(':') && checkDynamicMatch(menuPath, currentPath))) {
return [menu.path];
}
}
}
if (bestMatch) {
return bestMatch.parentId ? [bestMatch.path, bestMatch.parentId] : [bestMatch.path];
}
return null;
};

View File

@@ -36,7 +36,7 @@ export const en = {
apiKeyManagement: 'API KEY Management',
toolManagement: 'Tool Management',
emotionEngine: 'Emotion Engine',
emotionDetail: 'Emotion Memory',
statementDetail: 'Emotion Memory',
selfReflectionEngine: 'Self Reflection Engine',
},
dashboard: {
@@ -1037,7 +1037,24 @@ export const en = {
conversationMemory: 'Conversation Storage Content',
sortByTimeDesc: 'Sort by time in descending order',
editConfig: 'Edit Config',
chooseModel: 'Choose Model'
chooseModel: 'Choose Model',
nodeStatistics: 'Memory Classification',
total: 'Total',
Chunk: 'Long-term Memory',
MemorySummary: 'Episodic Memory',
Statement: 'Emotional Memory',
ExtractedEntity: 'Short-term Memory',
endUserProfile: 'Core Profile',
editEndUserProfile: 'Edit',
name: 'Name',
position: 'Position',
department: 'Department',
contact: 'Contact',
phone: 'Phone',
hire_date: 'Hire Date',
memoryContent: 'Memory Content',
created_at: 'Created At',
},
space: {
createSpace: 'Create Space',
@@ -1626,7 +1643,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
expect_improvement_tag: 'Only low threshold will record',
confidence: 'Confidence'
},
emotionDetail: {
statementDetail: {
wordCloud: 'Emotion Distribution Analysis',
pieces: 'items',
emotionTags: 'High-Frequency Emotion Keywords',

View File

@@ -36,7 +36,7 @@ export const zh = {
userMemoryDetail: '用户记忆详情',
toolManagement: '工具管理',
emotionEngine: '情感引擎',
emotionDetail: '情绪记忆',
statementDetail: '情绪记忆',
selfReflectionEngine: '反思引擎',
},
knowledgeBase: {
@@ -1114,6 +1114,23 @@ export const zh = {
occupation: '职业',
memories: '记忆',
expanded: '展开',
nodeStatistics: '记忆分类',
total: '总计',
Chunk: '长期记忆',
MemorySummary: '情景记忆',
Statement: '情绪记忆',
ExtractedEntity: '短期记忆',
endUserProfile: '核心档案',
editEndUserProfile: '编辑',
name: '姓名',
position: '职位',
department: '部门',
contact: '联系方式',
phone: '电话',
hire_date: '入职时间',
memoryContent: '记忆内容',
created_at: '创建时间',
},
space: {
createSpace: '创建空间',
@@ -1714,7 +1731,7 @@ export const zh = {
expect_improvement_tag: '仅低阈值会记录',
confidence: '置信度'
},
emotionDetail: {
statementDetail: {
wordCloud: '情感分布分析',
pieces: '条',
emotionTags: '高频情绪关键词',

View File

@@ -56,7 +56,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
SpaceManagement: lazy(() => import('@/views/SpaceManagement')),
ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')),
EmotionEngine: lazy(() => import('@/views/EmotionEngine')),
EmotionDetail: lazy(() => import('@/views/UserMemoryDetail/pages/EmotionDetail')),
StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')),
SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')),
Login: lazy(() => import('@/views/Login')),
InviteRegister: lazy(() => import('@/views/InviteRegister')),

View File

@@ -28,7 +28,7 @@
{ "path": "/api-key", "element": "ApiKeyManagement" },
{ "path": "/emotion-engine/:id", "element": "EmotionEngine" },
{ "path": "/reflection-engine/:id", "element": "SelfReflectionEngine" },
{ "path": "/user-memory/emotion/:id", "element": "EmotionDetail" },
{ "path": "/statement/:id", "element": "StatementDetail" },
{ "path": "/no-permission", "element": "NoPermission" },
{ "path": "/*", "element": "NotFound" }
]

View File

@@ -26,19 +26,6 @@
"sort": 0,
"subs": []
},
{
"id": 4,
"parent": 0,
"code": "tool",
"label": "工具管理",
"i18nKey": "menu.toolManagement",
"path": "/tool",
"enable": true,
"display": true,
"level": 1,
"sort": 1,
"subs": []
},
{
"id": 3,
"parent": 0,
@@ -251,15 +238,15 @@
"sort": 0,
"subs": [
{
"id": 81,
"parent": 8,
"code": "emotionDetail",
"id": 811,
"parent": 81,
"code": "statementDetail",
"label": "记忆详情",
"i18nKey": "menu.emotionDetail",
"path": "/user-memory/emotion/:id",
"i18nKey": "menu.statementDetail",
"path": "/statement/:id",
"enable": true,
"display": false,
"level": 2,
"level": 3,
"sort": 0,
"subs": null
}

View File

@@ -57,20 +57,47 @@ export const useMenu = create<MenuState>((set, get) => ({
const { allMenus } = get()
const menus = allMenus[source] || []
let result: MenuItem[] = []
const matchedMenu: MenuItem | undefined = menus.find(menu => menu.path === paths[paths.length - 1] || `${menu.id}` === paths[1]);
if (matchedMenu) {
let matchedSubMenu: MenuItem | undefined = undefined;
if (paths.length > 1 && matchedMenu?.subs?.length) {
matchedSubMenu = matchedMenu.subs.find(menu => menu.path === paths[0]);
console.log('updateBreadcrumbs paths:', paths);
if (paths.length === 3) {
// 三级菜单:[subSubPath, subId, menuId]
const menuId = paths[2];
const subId = paths[1];
const subSubPath = paths[0];
const matchedMenu = menus.find(menu => `${menu.id}` === menuId);
if (matchedMenu && matchedMenu.subs) {
const matchedSub = matchedMenu.subs.find(sub => `${sub.id}` === subId);
if (matchedSub && matchedSub.subs) {
const matchedSubSub = matchedSub.subs.find(subSub => subSub.path === subSubPath);
if (matchedSubSub) {
result = [
{ ...matchedMenu, subs: null },
{ ...matchedSub, subs: null },
{ ...matchedSubSub, subs: null }
];
}
}
}
result = [
{ ...matchedMenu, subs: null },
matchedSubMenu
].filter(item => item !== undefined) as MenuItem[]
} else {
result = [] as MenuItem[]
// 原有逻辑处理一级和二级菜单
const matchedMenu: MenuItem | undefined = menus.find(menu => menu.path === paths[paths.length - 1] || `${menu.id}` === paths[1]);
if (matchedMenu) {
let matchedSubMenu: MenuItem | undefined = undefined;
if (paths.length > 1 && matchedMenu?.subs?.length) {
matchedSubMenu = matchedMenu.subs.find(menu => menu.path === paths[0]);
}
result = [
{ ...matchedMenu, subs: null },
matchedSubMenu
].filter(item => item !== undefined) as MenuItem[]
} else {
result = [] as MenuItem[]
}
}
const allBreadcrumbs = { ...get().allBreadcrumbs, [source]: result }
set({ allBreadcrumbs })
localStorage.setItem('breadcrumbs', JSON.stringify(allBreadcrumbs))

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Row, Col, Form, App, Button, Switch, Space, Select } from 'antd';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import RbCard from '@/components/RbCard/Card';
import strategyImpactSimulator from '@/assets/images/memory/strategyImpactSimulator.svg'
import { getMemoryReflectionConfig, updateMemoryReflectionConfig, pilotRunMemoryReflectionConfig } from '@/api/memory'
@@ -139,6 +140,9 @@ const SelfReflectionEngine: React.FC = () => {
.then((res) => {
setResult(res as Result)
})
.catch(() => {
setRunLoading(false)
})
.finally(() => {
setRunLoading(false)
})

View File

@@ -8,22 +8,15 @@ 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'
import WordCloud from './components/WordCloud'
import EmotionTags from './components/EmotionTags'
import Health from './components/Health'
import Suggestions from './components/Suggestions'
const tagColors = ['21, 94, 239', '156, 111, 255', '255, 93, 52', '54, 159, 33']
import NodeStatistics from './components/NodeStatistics'
import EndUserProfile from './components/EndUserProfile'
interface TitleProps {
type: string;
@@ -34,15 +27,15 @@ interface TitleProps {
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]">
<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-[20px] rb:h-[20px] rb:mr-[8px]" />
<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-[20px]" onClick={() => onClick(type)}>
<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-[16px] rb:h-[16px] rb:ml-[4px]", {
<img src={down} className={clsx("rb:w-4 rb:h-4 rb:ml-1", {
'rb:rotate-180': !expanded,
})} />
</span>
@@ -52,47 +45,20 @@ const Title: FC<TitleProps> = ({ type, title, icon, t, expanded, onClick }) => (
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
@@ -105,42 +71,15 @@ const Neo4j: FC = () => {
})
}
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
return (
<Row gutter={[16, 16]} className="rb:pb-6">
<Col span={8}>
<Row gutter={[16, 16]}>
<Col span={24}>
<EndUserProfile />
</Col>
<Col span={24}>
<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
@@ -154,12 +93,12 @@ const Neo4j: FC = () => {
{expanded.includes('aboutUs') && (
<>
{loading.summary
? <Skeleton className="rb:mt-[16px]" />
? <Skeleton className="rb:mt-4" />
: summary
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
? <div className="rb:font-regular rb:leading-5.5 rb:pt-4">
{summary || '-'}
</div>
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</>
)}
@@ -182,28 +121,19 @@ const Neo4j: FC = () => {
</>
</RbCard>
</Col>
<Col span={24}>
<EmotionTags />
</Col>
</Row>
</Col>
<Col span={16}>
<Row gutter={[16, 16]}>
<Col span={24}>
<NodeStatistics />
</Col>
{/* 记忆洞察 */}
<Col span={24}>
<MemoryInsight />
</Col>
{/* 关系网络 + 记忆详情 */}
<RelationshipNetwork />
<Col span={12}>
<WordCloud />
</Col>
<Col span={12}>
<Health />
</Col>
<Col span={24}>
<Suggestions />
</Col>
</Row>
</Col>
</Row>

View File

@@ -28,15 +28,15 @@ interface TitleProps {
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]">
<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-[20px] rb:h-[20px] rb:mr-[8px]" />
<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-[20px]" onClick={() => onClick(type)}>
<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-[16px] rb:h-[16px] rb:ml-[4px]", {
<img src={down} className={clsx("rb:w-4 rb:h-4 rb:ml-1", {
'rb:rotate-180': !expanded,
})} />
</span>
@@ -115,20 +115,20 @@ const Rag: FC = () => {
}
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
return (
<Row gutter={[16, 16]} className="rb:pb-[24px]">
<Row gutter={[16, 16]} className="rb:pb-6">
<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]">
<div className="rb:flex-[0_0_auto] rb:w-20 rb:h-20 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-20 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name?.[0]}</div>
<div className="rb:text-[24px] rb:font-semibold rb:leading-8 rb:ml-4">
{name}<br/>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] rb:mt-[8px]">{personas?.join(' | ')}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 rb:mt-2">{personas?.join(' | ')}</div>
</div>
</div>
<div className="rb:flex rb:gap-[8px] rb:mb-[8px] rb:flex-wrap rb:mt-[25px]">
<div className="rb:flex rb:gap-2 rb:mb-2 rb:flex-wrap rb:mt-6.25">
{tags?.map((tag, tagIndex) => (
<span key={tag.tag} className="rb:rounded-[11px] rb:p-[0_8px] rb:leading-[22px] rb:border"
<span key={tag.tag} className="rb:rounded-[11px] rb:p-[0_8px] rb:leading-5.5 rb:border"
style={{
backgroundColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.08)`,
borderColor: `rgba(${tagColors[tagIndex % tagColors.length]}, 0.3)`,
@@ -141,9 +141,9 @@ const Rag: FC = () => {
</div>
{/* 记忆总量 */}
<div className="rb:font-regular rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px] rb:mb-[25px]">
<div className="rb:font-regular rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:mb-6.25">
{t('userMemory.totalNumOfMemories')}
<div className="rb:font-extrabold rb:text-[24px] rb:text-[#212332] rb:leading-[30px] rb:mt-[8px]">{memory || 0}</div>
<div className="rb:font-extrabold rb:text-[24px] rb:text-[#212332] rb:leading-7.5 rb:mt-2">{memory || 0}</div>
</div>
{/* 关于我 */}
@@ -159,12 +159,12 @@ const Rag: FC = () => {
{expanded.includes('aboutUs') && (
<>
{loading.summary
? <Skeleton className="rb:mt-[16px]" />
? <Skeleton className="rb:mt-4" />
: summary
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
? <div className="rb:font-regular rb:leading-5.5 rb:pt-4">
{summary || '-'}
</div>
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</>
)}
@@ -182,12 +182,12 @@ const Rag: FC = () => {
{expanded.includes('memoryInsight') && (
<>
{loading.insight
? <Skeleton className="rb:mt-[16px]" />
? <Skeleton className="rb:mt-4" />
: insight
? <div className="rb:font-regular rb:leading-[22px] rb:pt-[16px]">
? <div className="rb:font-regular rb:leading-5.5 rb:pt-4">
{insight || '-'}
</div>
: <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</>
)}

View File

@@ -10,11 +10,11 @@ interface CardProps {
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]', {
<div className={clsx('rb:h-full rb:border rb:rounded-xl rb:p-4 rb:border-[#DFE4ED]', {
'rb:bg-[#FBFDFF]': theme === 'default',
'rb:bg-[linear-gradient(180deg,_#F1F9FE_0%,_#FBFCFF_100%)]': theme === 'custom',
'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>}
{title && <div className="rb:text-[18px] rb:font-semibold rb:leading-6.25 rb:pb-4">{title}</div>}
{children}
</div>
)

View File

@@ -47,7 +47,7 @@ const ConversationMemory:FC = () => {
<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"
className="rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-gray-800 rb:text-sm"
>
<Markdown content={item} />
</div>

View File

@@ -13,7 +13,7 @@ interface TagList {
const EmotionTags: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [tagList, setTagList] = useState<TagList | null>(null)
const [data, setData] = useState<TagList | null>(null)
useEffect(() => {
getEmotionTagData()
@@ -25,18 +25,18 @@ const EmotionTags: FC = () => {
}
getWordCloud(id)
.then((res) => {
setTagList(res as TagList)
setData(res as TagList)
})
}
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
if (!tagList || tagList?.keywords.length === 0) return
if (!data || data?.keywords.length === 0) return
const timer = setInterval(() => {
setVisibleCount(prev => {
if (prev >= tagList?.keywords.length) {
if (prev >= data?.keywords.length) {
clearInterval(timer)
return prev
}
@@ -45,7 +45,7 @@ const EmotionTags: FC = () => {
}, 200)
return () => clearInterval(timer)
}, [tagList?.keywords.length])
}, [data?.keywords.length])
const getEmotionColor = (emotionType: string) => {
const colors: Record<string, string> = {
@@ -59,19 +59,19 @@ const EmotionTags: FC = () => {
return colors[emotionType] || '#8c8c8c'
}
const emotionStats = tagList?.keywords.reduce((acc, item) => {
const emotionStats = data?.keywords.reduce((acc, item) => {
acc[item.emotion_type] = (acc[item.emotion_type] || 0) + item.frequency
return acc
}, {} as Record<string, number>) ?? {}
return (
<RbCard
title={t('emotionDetail.emotionTags')}
title={t('statementDetail.emotionTags')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName='rb:p-0! rb:relative'
bodyClassName='rb:p-0! rb:pb-3! rb:relative'
>
{tagList
{data?.keywords && data?.keywords.length > 0
? <>
<div className="rb:flex rb:flex-wrap rb:items-center rb:gap-6 rb:text-sm rb:mt-3 rb:p-3 rb:bg-[#F0F3F8]">
{Object.entries(emotionStats).map(([type, count]) => {
@@ -79,13 +79,13 @@ const EmotionTags: FC = () => {
return (
<div key={type} className="rb:flex rb:items-center rb:gap-2">
<div className="rb:w-3 rb:h-3 rb:rounded-full" style={{ backgroundColor: getEmotionColor(type) }}></div>
<span className="rb:text-gray-600">{t(`emotionDetail.${type || 'neutral'}`)} ({count})</span>
<span className="rb:text-gray-600">{t(`statementDetail.${type || 'neutral'}`)} ({count})</span>
</div>
)
})}
</div>
<div className="rb:mt-6 rb:flex rb:items-center rb:flex-wrap rb:gap-3 rb:mb-3 rb:px-6">
{tagList.keywords.slice(0, visibleCount).map((item, index) => (
{data.keywords.slice(0, visibleCount).map((item, index) => (
<div
key={index}
className="rb:flex rb:items-center rb:justify-center rb:animate-fadeIn rb:px-4 rb:py-2 rb:rounded-full rb:text-white rb:font-medium"
@@ -102,7 +102,7 @@ const EmotionTags: FC = () => {
))}
</div>
</>
: <Empty />
: <Empty size={88} />
}
</RbCard>
)

View File

@@ -0,0 +1,72 @@
import { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Descriptions, Button } 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'
const EndUserProfile:FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const endUserProfileModalRef = useRef<EndUserProfileModalRef>(null)
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<EndUser | null>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
// 记忆洞察
const getData = () => {
if (!id) return
setLoading(true)
getEndUserProfile(id).then((res) => {
setData(res as EndUser)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
const formatItems = useCallback(() => {
if (!data) return []
return ['name', 'position', 'department', 'contact', 'phone', 'hire_date'].map(key => ({
key,
label: t(`userMemory.${key}`),
children: key === 'hire_date' ? 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)
}
return (
<RbCard
title={t('userMemory.endUserProfile')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
{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>
: <Empty size={80} />
}
<EndUserProfileModal
ref={endUserProfileModalRef}
refresh={getData}
/>
</RbCard>
)
}
export default EndUserProfile

View File

@@ -0,0 +1,128 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App, DatePicker } from 'antd';
import { useTranslation } from 'react-i18next';
import type { EndUser, EndUserProfileModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { updatedEndUserProfile, } from '@/api/memory'
import dayjs from 'dayjs';
const FormItem = Form.Item;
interface EndUserProfileModalProps {
refresh: () => void;
}
const EndUserProfileModal = forwardRef<EndUserProfileModalRef, EndUserProfileModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<EndUser>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = (user: EndUser) => {
form.setFieldsValue({
...user,
end_user_id: user.id,
hire_date: dayjs(user.hire_date)
});
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
updatedEndUserProfile({
...values,
hire_date: values.hire_date.valueOf()
})
.then(() => {
setLoading(false)
refresh()
handleClose()
message.success(t('common.saveSuccess'))
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('common.edit')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem name="end_user_id" hidden></FormItem>
<FormItem
name="name"
label={t('userMemory.name')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="position"
label={t('userMemory.position')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="department"
label={t('userMemory.department')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="contact"
label={t('userMemory.contact')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="phone"
label={t('userMemory.phone')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="hire_date"
label={t('userMemory.hire_date')}
>
<DatePicker className="rb:w-full" />
</FormItem>
</Form>
</RbModal>
);
});
export default EndUserProfileModal;

View File

@@ -56,12 +56,11 @@ const Health: FC = () => {
return (
<RbCard
title={t('emotionDetail.health')}
title={t('statementDetail.health')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
height="100%"
>
{health
{health?.health_score && health?.health_score > 0
? <>
<div className="rb:flex rb:justify-center rb:items-center">
<Progress
@@ -78,20 +77,20 @@ const Health: FC = () => {
{health.dimensions && <>
<div className="rb:flex rb:items-center rb:justify-between rb:mt-6">
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.positivity_rate')}</div>
<div className="rb:w-40 rb:mr-3">{t('statementDetail.positivity_rate')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.positivity_rate.score} />
</div>
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.stability')}</div>
<div className="rb:w-40 rb:mr-3">{t('statementDetail.stability')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.stability.score} />
</div>
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.resilience')}</div>
<div className="rb:w-40 rb:mr-3">{t('statementDetail.resilience')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.resilience.score} />
</div>
</>}
</>
: <Empty />
: <Empty size={88} className="rb:h-full" />
}
</RbCard>
)

View File

@@ -43,7 +43,7 @@ const MemoryInsight:FC = () => {
? <Skeleton />
: report
? <div className="rb:flex rb:flex-wrap rb:justify-between rb:h-full">
<div className="rb:leading-[22px]">
<div className="rb:leading-5.5">
{report|| '-'}
</div>
</div>

View File

@@ -0,0 +1,90 @@
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 { nodes: NodeStatisticsItem[], total: number }
setData(response.nodes)
setTotal(response.total)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
const handleViewDetail = (type: string) => {
switch (type) {
case 'Statement':
navigate(`/statement/${id}`)
break
}
}
return (
<RbCard
title={t('userMemory.nodeStatistics')}
extra={<div>{t('userMemory.total')}: {total}</div>}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bgColor="linear-gradient(180deg,#F1F9FE 0%, #FBFCFF 100%)"
height="100%"
>
{loading
? <Skeleton />
: data.length > 0
? <div className={`rb:w-full rb:grid rb:grid-cols-${data.length} 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 === 'Statement'
})}
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 === 'Statement' && <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

@@ -59,7 +59,7 @@ const PieCard: FC = () => {
{loading
? <Loading size={249} />
: !data || data.length === 0
? <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
? <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
: data && data.length > 0 &&
<ReactEcharts
option={{

View File

@@ -8,11 +8,12 @@ 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 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 },
@@ -35,89 +36,76 @@ const RelationshipNetwork:FC = () => {
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 }[] = []
const { nodes, edges, statistics } = res as GraphData
const curNodes: Node[] = []
const curEdges: Edge[] = []
const curNodeTypes = Object.keys(statistics.node_types)
list.forEach(item => {
if (item.edge && item.edge.target_id && item.edge.source_id) {
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 || 'Unknown'})
}
if (item.targetNode) {
nodes.push(item.targetNode)
categories.push({name: item.targetNode.entity_type || 'Unknown'})
}
// 计算每个节点的连接数
const connectionCount: Record<string, number> = {}
edges.forEach(edge => {
connectionCount[edge.source] = (connectionCount[edge.source] || 0) + 1
connectionCount[edge.target] = (connectionCount[edge.target] || 0) + 1
})
// 根据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)
// Calculate node frequency based on appearance in links
const nodeFrequency = new Map<string, number>()
// Count each node's appearance in links (both as source and target)
uniqueLinks.forEach(link => {
// Increment source node frequency (only if source exists and is a string)
if (typeof link.source === 'string') {
nodeFrequency.set(link.source, (nodeFrequency.get(link.source) || 0) + 1)
}
// Increment target node frequency (only if target exists and is a string)
if (typeof link.target === 'string') {
nodeFrequency.set(link.target, (nodeFrequency.get(link.target) || 0) + 1)
}
})
// Set minimum frequency to 1 for nodes not in any links
uniqueNodes.forEach(node => {
if (node.id && typeof node.id === 'string') {
if (!nodeFrequency.has(node.id)) {
nodeFrequency.set(node.id, 1)
}
}
})
uniqueNodes.map(item => {
const index = uniqueCategories.findIndex((n) => n.name === (item.entity_type || 'Unknown'))
item.category = index
// 处理节点数据
nodes.forEach(node => {
const connections = connectionCount[node.id] || 0
const categoryIndex = curNodeTypes.indexOf(node.label)
// Get frequency for the node, ensuring id is a string
const frequency = (item.id && typeof item.id === 'string') ? (nodeFrequency.get(item.id) || 1) : 1
// Set symbolSize based on frequency
// Adjust these thresholds based on expected frequency ranges
if (frequency <= 1) {
item.symbolSize = 5
} else if (frequency <= 10) {
item.symbolSize = 10
} else if (frequency <= 15) {
item.symbolSize = 15
} else if (frequency <= 20) {
item.symbolSize = 25
// 根据节点类型获取显示名称
let displayName = ''
switch (node.label) {
case 'Statement':
displayName = 'statement' in node.properties ? node.properties.statement?.slice(0, 5) || '' : ''
break
case 'ExtractedEntity':
displayName = 'name' in node.properties ? node.properties.name || '' : ''
break
default:
displayName = 'content' in node.properties ? node.properties.content?.slice(0, 5) || '' : ''
break
}
let symbolSize = 0
if (connections <= 1) {
symbolSize = 5
} else if (connections <= 10) {
symbolSize = 10
} else if (connections <= 15) {
symbolSize = 15
} else if (connections <= 20) {
symbolSize = 25
} else {
item.symbolSize = 35
symbolSize = 35
}
curNodes.push({
...node,
name: displayName,
category: categoryIndex >= 0 ? categoryIndex : 0,
symbolSize: symbolSize, // 根据连接数调整节点大小
itemStyle: {
color: ['#155EEF', '#4DA8FF', '#9C6FFF', '#8BAEF7', '#369F21', '#FF5D34', '#FF8A4C', '#FFB048'][categoryIndex % 8]
}
})
})
setNodes(uniqueNodes)
// 处理边数据
edges.forEach(edge => {
curEdges.push({
...edge,
source: edge.source,
target: edge.target,
value: edge.weight || 1
})
})
// 设置分类
const curCategories = curNodeTypes.map(type => ({ name: type }))
setNodes(curNodes)
setLinks(curEdges)
setCategories(curCategories)
})
}, [id])
useEffect(() => {
@@ -147,7 +135,6 @@ const RelationshipNetwork:FC = () => {
}
}, [nodes])
console.log('nodes', nodes)
return (
<>
{/* 关系网络 */}
@@ -157,7 +144,7 @@ const RelationshipNetwork:FC = () => {
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
<div className="rb:h-[496px]">
<div className="rb:h-124">
{nodes.length === 0 ? (
<Empty className="rb:h-full" />
) : (
@@ -175,8 +162,13 @@ const RelationshipNetwork:FC = () => {
links: links || [],
categories: categories || [],
roam: true,
label: {
show: true,
position: 'right',
formatter: '{b}',
},
lineStyle: {
color: 'source',
color: '#5B6167',
curveness: 0.3
},
force: {
@@ -218,19 +210,17 @@ const RelationshipNetwork:FC = () => {
// 处理节点点击事件
console.log('Node clicked:', params.data);
// 使用函数式更新避免状态依赖问题
setSelectedNode(prevSelected =>
prevSelected?.id === params.data.id ? null : params.data
)
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]">
<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-[20px]">
<img src={item.icon} className="rb:w-[20px] rb:h-[20px] rb:mr-[4px]" />
<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>
))}
@@ -244,27 +234,35 @@ const RelationshipNetwork:FC = () => {
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
{(!selectedNode || (!selectedNode?.description && !selectedNode?.entity_type))
{!selectedNode
? <Empty
url={empty}
title={t('userMemory.memoryDetailEmpty')}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:mb-[12px]"
className="rb:mb-3"
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 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>
}
{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>
<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>
</>
}
</RbCard>

View File

@@ -39,11 +39,11 @@ const Suggestions: FC = () => {
return (
<RbCard
title={t('emotionDetail.suggestions')}
title={t('statementDetail.suggestions')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
{suggestions
{suggestions?.suggestions && suggestions?.suggestions.length > 0
? <>
<RbAlert className="rb:mb-3">{suggestions.health_summary}</RbAlert>
{suggestions.suggestions.map((item, index) => (
@@ -54,7 +54,7 @@ const Suggestions: FC = () => {
</div>
))}
</>
: <Empty />
: <Empty size={88} className="rb:h-full" />
}
</RbCard>
)

View File

@@ -81,7 +81,7 @@ const WordCloud: FC = () => {
},
radar: {
indicator: radarData.map(item => ({
name: t(`emotionDetail.${item.name}`),
name: t(`statementDetail.${item.name}`),
max: 100,
min: 1
}))
@@ -99,12 +99,12 @@ const WordCloud: FC = () => {
return (
<RbCard
title={t('emotionDetail.wordCloud')}
title={t('statementDetail.wordCloud')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
height="100%"
>
{wordCloud
{wordCloud?.total_count && wordCloud?.total_count > 0
? <div className="rb:flex rb:h-100">
<ReactEcharts ref={chartRef} option={radarOption} style={{ width: '50%', height: '100%' }} />
<div className="rb:w-[50%] rb:pl-4 rb:flex rb:flex-col rb:justify-center">
@@ -113,8 +113,8 @@ const WordCloud: FC = () => {
{wordCloud.tags.map(item => (
<div key={item.emotion_type}>
<div className="rb:flex rb:items-center rb:justify-between rb:font-medium">
{t(`emotionDetail.${item.emotion_type}`)}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.count}{t('emotionDetail.pieces')}</div>
{t(`statementDetail.${item.emotion_type}`)}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.count}{t('statementDetail.pieces')}</div>
</div>
<Progress size="small" percent={item.percentage} />
</div>
@@ -122,7 +122,7 @@ const WordCloud: FC = () => {
</div>
</div>
</div>
: <Empty />
: <Empty size={88} />
}
</RbCard>
)

View File

@@ -1,5 +1,5 @@
import { type FC } from 'react'
import { Row, Col } from 'antd';
import { Row, Col, Space } from 'antd';
import WordCloud from '../components/WordCloud'
import EmotionTags from '../components/EmotionTags'
@@ -7,17 +7,15 @@ import Health from '../components/Health'
import Suggestions from '../components/Suggestions'
const EmotionDetail: FC = () => {
const StatementDetail: FC = () => {
return (
<Row gutter={[16, 16]}>
<Col span={12}>
<WordCloud />
</Col>
<Col span={12}>
<EmotionTags />
</Col>
<Col span={12}>
<Health />
<Space size={16} direction="vertical" className="rb:w-full">
<WordCloud />
<EmotionTags />
<Health />
</Space>
</Col>
<Col span={12}>
<Suggestions />
@@ -26,4 +24,4 @@ const EmotionDetail: FC = () => {
)
}
export default EmotionDetail
export default StatementDetail

View File

@@ -1,3 +1,5 @@
import type { Dayjs } from "dayjs";
export interface Data {
id: string | number
name: string;
@@ -39,30 +41,93 @@ export interface Data {
}[];
[key: string]: unknown;
}
export interface BaseProperties {
content: string;
created_at: number;
}
export interface StatementNodeProperties {
temporal_info: string;
stmt_type: string;
statement: string;
valid_at: string;
created_at: number;
}
export interface ExtractedEntityNodeProperties {
description: string;
name: string;
entity_type: string;
created_at: number;
}
export interface MemorySummaryNode {
id: string;
label: 'MemorySummary';
category: number;
symbolSize: number;
itemStyle: {
color: string;
}
name: string;
properties: {
content: string;
created_at: number;
}
caption: string;
}
export interface Node {
id: string;
description?: string;
label: 'Dialogue' | 'ExtractedEntity' | 'Chunk' | 'MemorySummary' | 'Statement';
category: number;
symbolSize: number;
name: string;
connect_strength?: string;
entity_idx: number;
entity_type?: string;
fact_summary?: string[];
category?: number;
symbolSize?: number;
itemStyle: {
color: string;
}
properties: BaseProperties | StatementNodeProperties | ExtractedEntityNodeProperties
caption: string;
}
export interface Edge {
statement: string;
rel_id: string;
source_id: string;
predicate: string;
target_id: string;
statement_id: string;
target?: string;
source?: string;
id: string;
source: string;
target: string;
type: string;
properties: {
run_id: string;
group_id: string;
created_at: string;
expired_at: string;
}
caption: string;
value: number;
weight: number;
}
export interface EdgeData {
sourceNode: Node;
edge: Edge;
targetNode: Node;
export interface GraphData {
nodes: Node[];
edges: Edge[];
statistics: {
total_nodes: number;
total_edges: number;
node_types: Record<string, number>;
edge_types: Record<string, number>;
}
}
export interface NodeStatisticsItem {
type: string;
count: number;
percentage: number;
}
export interface EndUser {
end_user_id: string;
id: string;
name: string;
position: string;
department: string;
contact: string;
phone: string;
hire_date: string | number | Dayjs
}
export interface EndUserProfileModalRef {
handleOpen: (vo: EndUser) => void;
}

View File

@@ -54,7 +54,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setChatList([])
}
const handleEditVariables = () => {
variableConfigModalRef.current?.handleOpen()
variableConfigModalRef.current?.handleOpen(variables)
}
const handleSave = (values: StartVariableItem[]) => {
setVariables([...values])
@@ -91,7 +91,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
}])
setChatList(prev => [...prev, {
role: 'assistant',
content: message,
content: '',
created_at: Date.now(),
}])

View File

@@ -12,12 +12,12 @@ interface VariableEditModalProps {
const VariableConfigModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
refresh,
variables
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<{variables: StartVariableItem[]}>();
const [loading, setLoading] = useState(false)
const [initialValues, setInitialValues] = useState<StartVariableItem[]>([])
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
@@ -26,9 +26,10 @@ const VariableConfigModal = forwardRef<VariableEditModalRef, VariableEditModalPr
setLoading(false)
};
const handleOpen = () => {
const handleOpen = (values: StartVariableItem[]) => {
setVisible(true);
form.setFieldsValue({variables: values})
setInitialValues([...values])
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
@@ -59,18 +60,18 @@ const VariableConfigModal = forwardRef<VariableEditModalRef, VariableEditModalPr
form={form}
layout="horizontal"
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
initialValues={{ variables: variables }}
>
<Form.List name="variables">
{(fields) => (
<>
{fields.map(({ name }, index) => {
const field = variables[index]
const field = initialValues[index]
return (
<Form.Item
key={name}
name={[name, 'value']}
label={field.type === 'boolean' ? undefined : `${field.name}·${field.description}`}
valuePropName={field.type === 'boolean' ? 'checked' : 'value'}
rules={[
{ required: field.required, message: field.type === 'boolean' ? t('common.pleaseSelect') : t('common.pleaseEnter') },
]}

View File

@@ -138,6 +138,15 @@ export const useWorkflowGraph = ({
})
graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
}
// 初始化完成后,将节点展示在可视区域内
if (nodes.length > 0 || edges.length > 0) {
setTimeout(() => {
if (graphRef.current) {
graphRef.current.centerContent()
}
}, 200)
}
}
const saveState = () => {

View File

@@ -75,7 +75,7 @@ export interface WorkflowConfig {
}
export interface VariableEditModalRef {
handleOpen: (values?: StartVariableItem) => void;
handleOpen: (values: StartVariableItem[]) => void;
}
export interface StartVariableItem {
name: string;