diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index cce52944..6d75aad3 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -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) => { diff --git a/web/src/assets/images/home/arrow_top_right_hover.svg b/web/src/assets/images/home/arrow_top_right_hover.svg new file mode 100644 index 00000000..903f9618 --- /dev/null +++ b/web/src/assets/images/home/arrow_top_right_hover.svg @@ -0,0 +1,16 @@ + + + 编组 16 + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/Empty/index.tsx b/web/src/components/Empty/index.tsx index 53f52941..6cc8cda0 100644 --- a/web/src/components/Empty/index.tsx +++ b/web/src/components/Empty/index.tsx @@ -27,7 +27,7 @@ const Empty: FC = ({
404 {title &&
{title}
} - {curSubTitle &&
{subTitle}
} + {curSubTitle &&
{curSubTitle}
}
); } diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 9aeeab6b..4f30c104 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -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(null) const userInfoModalRef = useRef(null) @@ -54,7 +55,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { key: '1', label: (<>
{user.username}
-
{user.email}
+
{user.email}
), }, { @@ -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 (
diff --git a/web/src/hooks/useNavigationBreadcrumbs.ts b/web/src/hooks/useNavigationBreadcrumbs.ts index c314399b..67af853e 100644 --- a/web/src/hooks/useNavigationBreadcrumbs.ts +++ b/web/src/hooks/useNavigationBreadcrumbs.ts @@ -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; }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 19558eb5..a08407a6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7fa7a94a..9006fcbb 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: '高频情绪关键词', diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index f5296aab..06032d5b 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -56,7 +56,7 @@ const componentMap: Record>> = 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')), diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index 1a192770..d4f6a12e 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -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" } ] diff --git a/web/src/store/menu.json b/web/src/store/menu.json index 7845815f..ce6486ad 100644 --- a/web/src/store/menu.json +++ b/web/src/store/menu.json @@ -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 } diff --git a/web/src/store/menu.ts b/web/src/store/menu.ts index f6a66077..63b9adf9 100644 --- a/web/src/store/menu.ts +++ b/web/src/store/menu.ts @@ -57,20 +57,47 @@ export const useMenu = create((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)) diff --git a/web/src/views/SelfReflectionEngine/index.tsx b/web/src/views/SelfReflectionEngine/index.tsx index f7505b4b..0802b706 100644 --- a/web/src/views/SelfReflectionEngine/index.tsx +++ b/web/src/views/SelfReflectionEngine/index.tsx @@ -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) }) diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 21a3de6e..b26a9069 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -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 = ({ type, title, icon, t, expanded, onClick }) => ( -
+
- + {title} - onClick(type)}> + onClick(type)}> {t(`userMemory.${expanded ? 'foldUp' : 'expanded'}`)} - @@ -52,47 +45,20 @@ const Title: FC = ({ type, title, icon, t, expanded, onClick }) => ( const Neo4j: FC = () => { const { t } = useTranslation() const { id } = useParams() - const [data, setData] = useState(null) const [expanded, setExpanded] = useState(['aboutUs', 'interestDistribution', 'importantRelationships', 'importantMomentsInLife']) const [summary, setSummary] = useState(null) const [loading, setLoading] = useState>({ - detail: false, summary: false, }) - const [memory, setMemory] = useState(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 ( - + + + -
-
{name?.[0]}
-
- {name}
-
{data?.tags?.join(' | ')}
-
-
- -
- {data?.hot_tags?.map((tag, tagIndex) => ( - - {tag.name}({tag.frequency}) - - ))} -
- - {/* 记忆总量 */} -
- {t('userMemory.totalNumOfMemories')} -
{memory || 0}
-
- {/* 关于我 */} <> { {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> diff --git a/web/src/views/UserMemoryDetail/Rag.tsx b/web/src/views/UserMemoryDetail/Rag.tsx index 0c83d4d3..12b38461 100644 --- a/web/src/views/UserMemoryDetail/Rag.tsx +++ b/web/src/views/UserMemoryDetail/Rag.tsx @@ -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" /> } </> )} diff --git a/web/src/views/UserMemoryDetail/components/Card.tsx b/web/src/views/UserMemoryDetail/components/Card.tsx index 68b44ccb..38939c87 100644 --- a/web/src/views/UserMemoryDetail/components/Card.tsx +++ b/web/src/views/UserMemoryDetail/components/Card.tsx @@ -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> ) diff --git a/web/src/views/UserMemoryDetail/components/ConversationMemory.tsx b/web/src/views/UserMemoryDetail/components/ConversationMemory.tsx index 3d56ce47..486ddd11 100644 --- a/web/src/views/UserMemoryDetail/components/ConversationMemory.tsx +++ b/web/src/views/UserMemoryDetail/components/ConversationMemory.tsx @@ -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> diff --git a/web/src/views/UserMemoryDetail/components/EmotionTags.tsx b/web/src/views/UserMemoryDetail/components/EmotionTags.tsx index 80c95499..76ffa4ac 100644 --- a/web/src/views/UserMemoryDetail/components/EmotionTags.tsx +++ b/web/src/views/UserMemoryDetail/components/EmotionTags.tsx @@ -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> ) diff --git a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx new file mode 100644 index 00000000..95d40d42 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx @@ -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 \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/EndUserProfileModal.tsx b/web/src/views/UserMemoryDetail/components/EndUserProfileModal.tsx new file mode 100644 index 00000000..947cf3c4 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/EndUserProfileModal.tsx @@ -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; \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/Health.tsx b/web/src/views/UserMemoryDetail/components/Health.tsx index 2f870b90..98ba5d5c 100644 --- a/web/src/views/UserMemoryDetail/components/Health.tsx +++ b/web/src/views/UserMemoryDetail/components/Health.tsx @@ -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> ) diff --git a/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx b/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx index 641a1351..1471a2d3 100644 --- a/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx +++ b/web/src/views/UserMemoryDetail/components/MemoryInsight.tsx @@ -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> diff --git a/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx b/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx new file mode 100644 index 00000000..82c633a0 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx @@ -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 \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/PieCard.tsx b/web/src/views/UserMemoryDetail/components/PieCard.tsx index 2063ab08..982a8be6 100644 --- a/web/src/views/UserMemoryDetail/components/PieCard.tsx +++ b/web/src/views/UserMemoryDetail/components/PieCard.tsx @@ -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={{ diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index 59513225..f579b990 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -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> diff --git a/web/src/views/UserMemoryDetail/components/Suggestions.tsx b/web/src/views/UserMemoryDetail/components/Suggestions.tsx index 1c1792fb..0346f9dc 100644 --- a/web/src/views/UserMemoryDetail/components/Suggestions.tsx +++ b/web/src/views/UserMemoryDetail/components/Suggestions.tsx @@ -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> ) diff --git a/web/src/views/UserMemoryDetail/components/WordCloud.tsx b/web/src/views/UserMemoryDetail/components/WordCloud.tsx index 7d59763b..64f2bfaa 100644 --- a/web/src/views/UserMemoryDetail/components/WordCloud.tsx +++ b/web/src/views/UserMemoryDetail/components/WordCloud.tsx @@ -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> ) diff --git a/web/src/views/UserMemoryDetail/pages/EmotionDetail.tsx b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx similarity index 59% rename from web/src/views/UserMemoryDetail/pages/EmotionDetail.tsx rename to web/src/views/UserMemoryDetail/pages/StatementDetail.tsx index 987c7a3f..e6ddfd20 100644 --- a/web/src/views/UserMemoryDetail/pages/EmotionDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx @@ -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 \ No newline at end of file +export default StatementDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index e2f31adf..10b0c2fa 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -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; } \ No newline at end of file diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 09e47f61..340f4f31 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -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(), }]) diff --git a/web/src/views/Workflow/components/Chat/VariableConfigModal.tsx b/web/src/views/Workflow/components/Chat/VariableConfigModal.tsx index 22fe8f1b..fd422b42 100644 --- a/web/src/views/Workflow/components/Chat/VariableConfigModal.tsx +++ b/web/src/views/Workflow/components/Chat/VariableConfigModal.tsx @@ -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') }, ]} diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 3ec141dc..564d75c8 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -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 = () => { diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index c9e76dad..6debcad6 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -75,7 +75,7 @@ export interface WorkflowConfig { } export interface VariableEditModalRef { - handleOpen: (values?: StartVariableItem) => void; + handleOpen: (values: StartVariableItem[]) => void; } export interface StartVariableItem { name: string;