diff --git a/web/package.json b/web/package.json index 9d157982..e28e8b56 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.18", "echarts": "^5.6.0", "echarts-for-react": "^3.0.2", + "echarts-wordcloud": "^2.1.0", "i18next": "^25.6.0", "js-yaml": "^4.1.1", "lexical": "^0.39.0", diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 750f559c..39136da3 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -134,6 +134,54 @@ export const getEmotionSuggestions = (group_id: string) => { export const analyticsRefresh = (end_user_id: string) => { return request.post('/memory-storage/analytics/generate_cache', { end_user_id }) } +// 遗忘 +export const getForgetStats = (group_id: string) => { + return request.get(`/memory/forget/stats`, { group_id }) +} +// 隐性记忆-偏好 +export const getImplicitPreferences = (end_user_id: string) => { + return request.get(`/memory/implicit-memory/preferences/${end_user_id}`) +} +// 隐性记忆-核心特质 +export const getImplicitPortrait = (end_user_id: string) => { + return request.get(`/memory/implicit-memory/portrait/${end_user_id}`) +} +// 隐性记忆-兴趣领域分布 +export const getImplicitInterestAreas = (end_user_id: string) => { + return request.get(`/memory/implicit-memory/interest-areas/${end_user_id}`) +} +// 隐性记忆-用户习惯分析 +export const getImplicitHabits = (end_user_id: string) => { + return request.get(`/memory/implicit-memory/habits/${end_user_id}`) +} +// 短期记忆 +export const getShortTerm = (end_user_id: string) => { + return request.get(`/memory/short/short_term`, { end_user_id }) +} +// 感知记忆-视觉记忆 +export const getPerceptualLastVisual = (end_user: string) => { + return request.get(`/memory/perceptual/${end_user}/last_visual`) +} +// 感知记忆-音频记忆 +export const getPerceptualLastListen = (end_user: string) => { + return request.get(`/memory/perceptual/${end_user}/last_listen`) +} +// 感知记忆-文本记忆 +export const getPerceptualLastText = (end_user: string) => { + return request.get(`/memory/perceptual/${end_user}/last_text`) +} +// 感知记忆-感知记忆时间线 +export const getPerceptualTimeline = (end_user: string) => { + return request.get(`/memory/perceptual/${end_user}/timeline`) +} +// 情景记忆-总览 +export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => { + return request.post(`/memory-storage/classifications/episodic-memory`, data) +} +export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => { + return request.post(`/memory-storage/classifications/episodic-memory-details`, data) +} + /*************** end 用户记忆 相关接口 ******************************/ diff --git a/web/src/assets/images/userMemory/arrow_right_hover.svg b/web/src/assets/images/userMemory/arrow_right_hover.svg new file mode 100644 index 00000000..0fed7c6b --- /dev/null +++ b/web/src/assets/images/userMemory/arrow_right_hover.svg @@ -0,0 +1,14 @@ + + + 编组 5 + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/userMemory/shortTerm.png b/web/src/assets/images/userMemory/shortTerm.png new file mode 100644 index 00000000..37a880ec Binary files /dev/null and b/web/src/assets/images/userMemory/shortTerm.png differ diff --git a/web/src/assets/images/userMemory/up_border.svg b/web/src/assets/images/userMemory/up_border.svg new file mode 100644 index 00000000..a7fe9978 --- /dev/null +++ b/web/src/assets/images/userMemory/up_border.svg @@ -0,0 +1,14 @@ + + + 下拉备份 + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/userMemory/view.svg b/web/src/assets/images/userMemory/view.svg new file mode 100644 index 00000000..642841ae --- /dev/null +++ b/web/src/assets/images/userMemory/view.svg @@ -0,0 +1,19 @@ + + + 查看 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/userMemory/view_hover.svg b/web/src/assets/images/userMemory/view_hover.svg new file mode 100644 index 00000000..642841ae --- /dev/null +++ b/web/src/assets/images/userMemory/view_hover.svg @@ -0,0 +1,19 @@ + + + 查看 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/StatusTag/index.tsx b/web/src/components/StatusTag/index.tsx index 70b720b5..273fff1b 100644 --- a/web/src/components/StatusTag/index.tsx +++ b/web/src/components/StatusTag/index.tsx @@ -3,23 +3,27 @@ import { Tag } from 'antd'; import clsx from 'clsx'; interface StatusTagProps { - status: 'success' | 'error' | 'warning', + status: 'success' | 'error' | 'warning' | 'default' | 'lightBlue' | 'purple', text: string; } const Colors = { success: 'rb:bg-[#369F21]', error: 'rb:bg-[#FF5D34]', warning: 'rb:bg-[#FF8A4C]', + default: 'rb:bg-[#155EEF]', + lightBlue: 'rb:bg-[#4DA8FF]', + purple: 'rb:bg-[#9C6FFF]' } const StatusTag: FC = ({ status, text }) => { + console.log('status', status) return ( - - + + { text } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6154f439..cbf2ed8d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1229,6 +1229,23 @@ export const en = { growth_trajectory: 'Growth Trajectory', personality: 'Personality Traits', core_values: 'Core Values', + + Statement_emotion_keywords: 'Emotion Keywords', + Statement_emotion_type: 'Emotion Type', + Statement_emotion_subject: 'Emotion Subject', + Statement_importance_score: 'Importance Score', + + ExtractedEntity_description: 'Description', + ExtractedEntity_name: 'Content', + ExtractedEntity_entity_type: 'Type', + ExtractedEntity_created_at: 'Created At', + ExtractedEntity_aliases: 'Aliases', + ExtractedEntity_connect_strngth: 'Connection Strength', + ExtractedEntity_importance_score: 'Importance Score', + + associative_memory: 'Associative Memory', + unix: 'items', + completeMemory: 'Complete Memory', }, space: { createSpace: 'Create Space', @@ -2156,5 +2173,107 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re orderPayInfo: 'Payment Information', create_time: 'Creation Time', }, + forgetDetail: { + title: 'The forgetting management system helps AI intelligently manage memory lifecycle by automatically identifying low-value memories, setting forgetting strategies, and executing regular cleanup to optimize memory storage space and improve retrieval efficiency.', + overviewTitle: 'Core Metrics Overview', + totalMemory: 'Total Memory', + MemoryHealth: 'Memory Health', + riskOfForgetting: 'Forgetting Risk', + statement_count: 'Statements', + entity_count: 'Entities', + summary_count: 'Summaries', + chunk_count: 'Chunks', + healthStatus: 'Health Status', + average: 'Average Activation Value', + threshold: 'Threshold Reference:', + unhealthy: 'Unhealthy', + healthy: 'Healthy', + low_nodes: 'Low Activation Nodes', + memoryHealthVisualization: 'Memory Health Visualization', + activationValueDistribution: 'Activation Value Distribution', + forgettingTrend: 'Forgetting Trend (Last 7 Days)', + + nodes_without_activation: 'Observation Zone', + low_activation_nodes: 'Forgetting Zone', + health_nodes: 'Healthy Zone', + average_activation: 'Average Activation Value', + merged_count: 'Daily Merged Node Count', + + pending_nodes: 'Risk Node Forgetting Pool', + content_summary: 'Content Summary', + node_type: 'Node Type', + last_access_time: 'Last Activation Time', + activation_value: 'Current Activation Value', + }, + episodicDetail: { + title: 'Record every important scene you have truly experienced', + total_all: 'Total Episodic Memories', + all: "All", + today: 'Today', + this_week: 'This Week', + this_month: 'This Month', + conversation: "Conversation", + project_work: "Project/Work", + learning: "Learning", + decision: "Decision", + important_event: "Important Event", + titleKeywordPlaceholder: 'Search episode title or content', + curResult: 'Current Filter Results', + unix: 'items', + created: 'Occurrence Time', + episodic_type: 'Episode Type', + involved_objects: 'Involved Objects', + content_records: 'Episode Content Records', + emotion: 'Emotion and State Records', + }, + implicitDetail: { + title: 'The invisible forces that shaped me', + preferences: 'My Subconscious Preferences', + preferencesDetail: 'Association Network', + portraitTitle: 'My Subconscious Portrait', + portraitSubTitle: 'Personalized insights generated by AI based on your preference tags', + portrait: 'Core Traits', + aesthetic: 'Aesthetic Driven', + creativity: 'Creative Thinking', + literature: 'Cultural Sensitivity', + technology: 'Technology Affinity', + interestAreas: 'Interest Area Distribution', + art: 'Art & Design', + music: 'Music & Culture', + tech: 'Technology & Future', + lifestyle: 'Lifestyle', + habits: 'User Habit Analysis', + habitsSubTitle: 'Habit characteristics identified based on your behavior patterns', + context_details: 'Preference Details', + supporting_evidence: 'Preference Source', + specific_examples: 'Source', + }, + shortTermDetail: { + title: 'Short-term memory is the "workbench" of the AI system, connecting instant conversations with long-term knowledge bases. Through real-time capture, deep retrieval, intelligent extraction and filtering transformation, temporary unstructured information is converted into valuable long-term knowledge.', + retrieval_number: 'Retrieval Count', + entity: 'Extracted Entities', + long_term_number: 'Long-term Candidates', + shortTermTitle: 'Deep Retrieval & Extended Answer Area', + shortTermSubTitle: 'Stores deep information retrieval performed to answer questions and the extended answers generated from it, including original questions, retrieved information, and generated answers.', + longTermTitle: 'Long-term Memory Candidate Pool', + longTermSubTitle: 'Aggregates short-term memory, filters and prepares content for storage in long-term memory. This is the "transfer station" and "filter" from short-term to long-term memory.', + answer: 'Answer', + query: 'Question', + noAnswer: 'No reply yet', + }, + perceptualDetail: { + last_visual: 'Visual Perception Stream', + last_listen: 'Auditory Perception Stream', + last_text: 'Text Perception', + summary: 'Summary', + keywords: 'Keywords', + topic: 'Topic', + domain: 'Domain', + scene: 'Scene', + speaker_count: 'Number of Speakers', + section_count: 'Number of Sections', + timeLine: 'Perception Timeline', + lastInfo: 'Real-time Perception Dashboard', + } }, }; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1fa15454..2642f20b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1310,6 +1310,23 @@ export const zh = { growth_trajectory: '成长轨迹', personality: '性格特点', core_values: '核心价值观', + + Statement_emotion_keywords: '情感关键词', + Statement_emotion_type: '情感类型', + Statement_emotion_subject: '情感主体', + Statement_importance_score: '重要性评分', + + ExtractedEntity_description: '描述', + ExtractedEntity_name: '内容', + ExtractedEntity_entity_type: '类型', + ExtractedEntity_created_at: '创建时间', + ExtractedEntity_aliases: '别名', + ExtractedEntity_connect_strngth: '连接强度', + ExtractedEntity_importance_score: '重要性评分', + + associative_memory: '关联记忆', + unix: '个', + completeMemory: '完整记忆', }, space: { createSpace: '创建空间', @@ -2262,6 +2279,101 @@ export const zh = { totalMemory: '记忆总量', MemoryHealth: '记忆健康度', riskOfForgetting: '遗忘风险', + statement_count: '陈述', + entity_count: '实体', + summary_count: '摘要', + chunk_count: '片段', + healthStatus: '健康状态', + average: '平均激活值', + threshold: '阈值参考:', + unhealthy: '不健康', + healthy: '健康', + low_nodes: '低激活节点', + memoryHealthVisualization: '记忆健康可视化', + activationValueDistribution: '激活值分布', + forgettingTrend: '遗忘趋势(近7天)', + + nodes_without_activation: '观察区', + low_activation_nodes: '遗忘区', + health_nodes: '健康区', + average_activation: '平均激活值', + merged_count: '每日融合节点数', + + pending_nodes: '风险节点遗忘池', + content_summary: '内容摘要', + node_type: '节点类型', + last_access_time: '最后激活时间', + activation_value: '当前激活值', + }, + episodicDetail: { + title: '记录你真实经历过的每一个重要场景', + total_all: '情景记忆总数', + all: "全部", + today: '今天', + this_week: '本周', + this_month: '本月', + conversation: "对话", + project_work: "项目/工作", + learning: "学习", + decision: "决策", + important_event: "重要事件", + titleKeywordPlaceholder: '搜索情景标题或内容', + curResult: '当前筛选结果', + unix: '条', + created: '发生时间', + episodic_type: '情景类型', + involved_objects: '涉及对象', + content_records: '情景内容记录', + emotion: '情绪与状态记录', + }, + implicitDetail: { + title: '那些塑造了我的无形力量', + preferences: '我的潜意识偏好', + preferencesDetail: '的联想网络', + portraitTitle: '我的潜意识画像', + portraitSubTitle: '基于您的偏好标签,AI为您生成的个性化洞察', + portrait: '核心特质', + aesthetic: '审美驱动', + creativity: '创造性思维', + literature: '文化敏感度', + technology: '技术亲和力', + interestAreas: '兴趣领域分布', + art: '艺术与设计', + music: '音乐与文化', + tech: '科技与未来', + lifestyle: '生活方式', + habits: '用户习惯分析', + habitsSubTitle: '基于您的行为模式识别的习惯特征', + context_details: '偏好详情', + supporting_evidence: '偏好来源', + specific_examples: '来源', + }, + shortTermDetail: { + title: '短期记忆是AI系统的"工作台",连接即时对话与长期知识库。通过实时捕获、深度检索、智能提取和筛选转化,将临时的非结构化信息转化为有价值的长期知识。', + retrieval_number: '检索次数', + entity: '提取实体', + long_term_number: '长期候选', + shortTermTitle: '深度检索与扩展答案区', + shortTermSubTitle: '存放为回答问题而进行的深度信息检索和由此生成的扩展答案,包含原始问题、检索信息和生成答案。', + longTermTitle: '长期记忆候选池', + longTermSubTitle: '聚合短期记忆,筛选并准备存入长期记忆的内容。这是从短时记忆到长时记忆的"中转站"和"过滤器"。', + answer: '回答', + query: '问题', + noAnswer: '暂无回复', + }, + perceptualDetail: { + last_visual: '视觉感知流', + last_listen: '听觉感知流', + last_text: '文本感知', + summary: '摘要', + keywords: '关键词', + topic: '主题', + domain: '领域', + scene: '场景', + speaker_count: '对话人数', + section_count: '段落数', + timeLine: '感知时间线', + lastInfo: '实时感知仪表盘', } }, } \ No newline at end of file diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index bac985bf..bc2f61e6 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -59,6 +59,9 @@ const componentMap: Record>> = ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')), EmotionEngine: lazy(() => import('@/views/EmotionEngine')), StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')), + ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')), + MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')), + GraphDetail: lazy(() => import('@/views/UserMemoryDetail/pages/GraphDetail')), SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')), OrderPayment: lazy(() => import('@/views/OrderPayment')), OrderHistory: lazy(() => import('@/views/OrderHistory')), diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index a29d1c63..2f332b72 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -43,7 +43,9 @@ { "path": "/application/config/:id", "element": "ApplicationConfig" }, { "path": "/conversation/:token", "element": "Conversation" }, { "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" }, - { "path": "/statement/:id", "element": "StatementDetail" } + { "path": "/statement/:id", "element": "StatementDetail" }, + { "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" }, + { "path": "/graph/:id", "element": "GraphDetail" } ] }, { diff --git a/web/src/views/ApplicationConfig/Cluster.tsx b/web/src/views/ApplicationConfig/Cluster.tsx index ce639d62..66245446 100644 --- a/web/src/views/ApplicationConfig/Cluster.tsx +++ b/web/src/views/ApplicationConfig/Cluster.tsx @@ -138,7 +138,7 @@ const Cluster = forwardRef((_props, ref) => {
- + - + {countList.map(key => ( -
-
+
+
{countData[key] || 0}{key === 'avgInteractionTime' ? 's' : ''} - +
-
{t(`userMemory.${key}`)}
+
{t(`userMemory.${key}`)}
))} @@ -140,22 +140,22 @@ export default function UserMemory() { return (
handleViewDetail(end_user.id)} >
-
{name[0]}
-
+
{name[0]}
+
{name || '-'}
-
+
-
{memory_num.total || 0}
-
{t(`userMemory.knowledgeEntryCount`)}
+
{memory_num.total || 0}
+
{t(`userMemory.knowledgeEntryCount`)}
diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index f638b21f..a6dedd8b 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useRef, useState } from 'react' +import { type FC, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { Row, Col, Space, Button } from 'antd' import { useTranslation } from 'react-i18next'; @@ -25,7 +25,7 @@ const Neo4j: FC = () => { const aboutMeRef = useRef(null) const handleNameUpdate = (data: { other_name?: string; id: string }) => { - setName(data.other_name ?? data.id) + setName(data.other_name && data.other_name !== '' ? data.other_name : data.id) } const handleRefresh = () => { diff --git a/web/src/views/UserMemoryDetail/components/ActivationMetricsPieCard.tsx b/web/src/views/UserMemoryDetail/components/ActivationMetricsPieCard.tsx new file mode 100644 index 00000000..eedd3e7e --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/ActivationMetricsPieCard.tsx @@ -0,0 +1,124 @@ +import { type FC, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import ReactEcharts from 'echarts-for-react'; +import Loading from '@/components/Empty/Loading' +import Empty from '@/components/Empty' +import RbCard from '@/components/RbCard/Card' + +interface ActivationMetricsPieCardProps { + chartData: Array>; + loading: boolean; +} +const Colors = ['#155EEF', '#FFB048', '#FF5D34'] + +const ActivationMetricsPieCard: FC = ({ chartData, loading }) => { + const { t } = useTranslation() + const chartRef = useRef(null); + const resizeScheduledRef = useRef(false) + + useEffect(() => { + const handleResize = () => { + if (chartRef.current && !resizeScheduledRef.current) { + resizeScheduledRef.current = true + requestAnimationFrame(() => { + chartRef.current?.getEchartsInstance().resize(); + resizeScheduledRef.current = false + }); + } + } + + const resizeObserver = new ResizeObserver(handleResize) + const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement + if (chartElement) { + resizeObserver.observe(chartElement) + } + + return () => { + resizeObserver.disconnect() + } + }, [chartData]) + + return ( + + {loading + ? + : !chartData || chartData.length === 0 + ? + : + } + + ) +} + +export default ActivationMetricsPieCard diff --git a/web/src/views/UserMemoryDetail/components/Habits.tsx b/web/src/views/UserMemoryDetail/components/Habits.tsx new file mode 100644 index 00000000..746d7164 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/Habits.tsx @@ -0,0 +1,85 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Skeleton, Space, Progress } from 'antd'; +import RbCard from '@/components/RbCard/Card' +import Empty from '@/components/Empty' +import { + getImplicitHabits, +} from '@/api/memory' + +interface HabitsItem { + habit_description: string; + frequency_pattern: string; + time_context: string; + confidence_level: string; + supporting_summaries: string[]; + first_observed: string; + last_observed: string; + is_current: boolean; + specific_examples: string[]; +} + +const Habits: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState([]) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + // 记忆洞察 + const getData = () => { + if (!id) return + setLoading(true) + getImplicitHabits(id).then((res) => { + const response = res as HabitsItem[] + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( + <> +
{t('implicitDetail.habits')}
+
{t('implicitDetail.habitsSubTitle')}
+ + {loading + ? + : data.length === 0 + ? + : + {data.map((vo, voIdx) => ( +
+
+
+
{vo.habit_description}
+
{vo.time_context}
+
+
{vo.confidence_level}%
+
+ + {vo.specific_examples.length > 0 && <> +
{t('implicitDetail.specific_examples')}
+
+ {vo.specific_examples.map((item, index) => ( +
- {item}
+ ))} +
+ } + +
+ ))} +
+ } +
+ + ) +} +export default Habits \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx new file mode 100644 index 00000000..357336f4 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx @@ -0,0 +1,75 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Skeleton, Progress } from 'antd'; +import RbCard from '@/components/RbCard/Card' +import { + getImplicitInterestAreas, +} from '@/api/memory' + +interface Item { + category_name: string; + percentage: number; + evidence: string[]; + trending_direction: string | null; +} +interface InterestAreasItem { + user_id: string; + analysis_timestamp: number | string; + total_summaries_analyzed: number; + tech: Item; + lifestyle: Item; + music: Item; + art: Item; +} + +const InterestAreas: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as InterestAreasItem) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + // 记忆洞察 + const getData = () => { + if (!id) return + setLoading(true) + getImplicitInterestAreas(id).then((res) => { + const response = res as InterestAreasItem + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( + + {loading + ? + :
+ {(['art', 'music', 'tech', 'lifestyle'] as const).map((key) => { + return ( +
+
+
{t(`implicitDetail.${key}`)}
+ {data[key]?.percentage ?? 0}% +
+ +
+ ) + })} +
+ } +
+ ) +} +export default InterestAreas \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx b/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx index 288e11c0..8815c66d 100644 --- a/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx +++ b/web/src/views/UserMemoryDetail/components/NodeStatistics.tsx @@ -4,7 +4,6 @@ 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' @@ -15,11 +14,25 @@ const BG_LIST = [ 'rb:bg-[linear-gradient(316deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]', 'rb:bg-[linear-gradient(316deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]', 'rb:bg-[linear-gradient(314deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]', - 'rb:bg-[linear-gradient(314deg,rgba(255,93,52,0.06)_0%,rgba(251,253,255,0)_100%)]', - 'rb:bg-[linear-gradient(180deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]', - 'rb:bg-[linear-gradient(180deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]', - 'rb:bg-[linear-gradient(180deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]', - 'rb:bg-[]', + 'rb:bg-[linear-gradient(332deg,rgba(255,93,52,0.06)_0%,rgba(251,253,255,0)_100%)]', + 'rb:bg-[linear-gradient(313deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]', + 'rb:bg-[linear-gradient(332deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]', +] +const typeList = [ + { key: 'PERCEPTUAL_MEMORY', bg: 0 }, + { key: 'WORKING_MEMORY', bg: 1 }, + { key: 'EMOTIONAL_MEMORY', bg: 2 }, + { key: 'SHORT_TERM_MEMORY', bg: 3 }, + { + key: 'LONG_TERM_MEMORY', + bg: 4, + children: [ + { key: 'IMPLICIT_MEMORY' }, + { key: 'EPISODIC_MEMORY' }, + { key: 'EXPLICIT_MEMORY' } + ] + }, + { key: 'FORGETTING_MANAGEMENT', bg: 5 }, ] const NodeStatistics: FC = () => { @@ -52,43 +65,59 @@ const NodeStatistics: FC = () => { }) } const handleViewDetail = (type: string) => { - switch (type) { - case 'EMOTIONAL_MEMORY': - navigate(`/statement/${id}`) - break - } + navigate(`/user-memory/detail/${id}/${type}`) } + const renderCard = (key: string, bgIndex: number | null, isChild: boolean = false) => { + const item = data.find((item) => item.type === key) + return ( +
handleViewDetail(key)} + > +
+
+ {t(`userMemory.${key}`)} +
+
+
+
{item?.count ?? 0}
+
+ ) + } + return ( {t('userMemory.nodeStatistics')} ({t('userMemory.total')}: {total})} headerType="borderless" > {loading - ? - : data && data.length > 0 - ?
- {data.map((vo, index) => ( -
handleViewDetail(vo.type)} - > -
-
- {t(`userMemory.${vo.type}`)} -
- {vo.type === 'EMOTIONAL_MEMORY' &&
} + ? + :
+ {typeList.map((vo) => { + if (!vo.children) { + return
{renderCard(vo.key, vo.bg)}
+ } + return ( +
+
{t(`userMemory.${vo.key}`)}
+
+ {vo.children.map((child) =>
{renderCard(child.key, null, true)}
)}
-
{vo.count ?? 0}
- ))} + ) + })}
- : - } + } ) } diff --git a/web/src/views/UserMemoryDetail/components/PageHeader.tsx b/web/src/views/UserMemoryDetail/components/PageHeader.tsx index dda60e7e..56da70e0 100644 --- a/web/src/views/UserMemoryDetail/components/PageHeader.tsx +++ b/web/src/views/UserMemoryDetail/components/PageHeader.tsx @@ -9,7 +9,7 @@ const { Header } = Layout; interface ConfigHeaderProps { name?: string; operation?: ReactNode; - source?: 'detail' | 'statement' + source?: 'detail' | 'node' } const PageHeader: FC = ({ name, diff --git a/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx new file mode 100644 index 00000000..d3788a74 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx @@ -0,0 +1,120 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Skeleton, Space, Tooltip, Image } from 'antd'; +import RbCard from '@/components/RbCard/Card' +import { + getPerceptualLastVisual, + getPerceptualLastListen, + getPerceptualLastText, +} from '@/api/memory' + +interface PerceptualLastInfoItem { + id: string; + file_name: string; + file_ext: string; + file_path: string; + storage_type: number; + summary: string; + keywords: string[]; + topic: string; + domain: string; + created_time: number | string; + scene: string[] + speaker_count: number; + section_count: number; +} + +const KEYS = { + last_visual: ['summary', 'keywords', 'topic', 'domain', 'scene'], + last_listen: ['summary', 'keywords', 'topic', 'domain', 'speaker_count'], + last_text: ['summary', 'keywords', 'topic', 'domain', 'section_count'], +} + +const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text' }> = ({ type }) => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as PerceptualLastInfoItem) + + useEffect(() => { + if (!id) return + getData() + }, [id, type]) + const getData = () => { + if (!id || !type) return + setLoading(true) + const request = type === 'last_visual' + ? getPerceptualLastVisual(id) + : type === 'last_listen' + ? getPerceptualLastListen(id) + : getPerceptualLastText(id) + request.then((res) => { + const response = res as PerceptualLastInfoItem + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( + + {loading + ? + :
+
+ {data.file_path ? ( + type === 'last_visual' ? ( + /\.(mp4|webm|ogg|mov)$/i.test(data.file_name) ? ( + + ) : /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(data.file_name) ? ( + {data.file_name} + // {data.file_name} + ) : ( +
{data.file_name}
+ ) + ) : type === 'last_listen' && /\.(mp3|wav|ogg|m4a|aac)$/i.test(data.file_name) ? ( + + ) : ( +
{data.file_name}
+ ) + ) : ( +
No file
+ )} +
+ + {KEYS[type].map(key => { + const value = (data as any)[key] + return ( +
+
{t(`perceptualDetail.${key}`)}
+ {key === 'summary' ? ( + +
+ {typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'} +
+
+ ) + :
+ {typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'} +
+ } +
+ ) + })} +
+
+ } +
+ ) +} +export default PerceptualLastInfo \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/Portrait.tsx b/web/src/views/UserMemoryDetail/components/Portrait.tsx new file mode 100644 index 00000000..3164ae06 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/Portrait.tsx @@ -0,0 +1,77 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Skeleton, Progress } from 'antd'; +import RbCard from '@/components/RbCard/Card' +import { + getImplicitPortrait, +} from '@/api/memory' + +interface Item { + dimension_name: string; + percentage: number; + evidence: string[]; + reasoning: string; + confidence_level: string; +} +interface PortraitItem { + user_id: string; + analysis_timestamp: number | string; + total_summaries_analyzed: number; + historical_trends: null; + creativity: Item; + aesthetic: Item; + technology: Item; + literature: Item; +} + +const Portrait: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as PortraitItem) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + const getData = () => { + if (!id) return + setLoading(true) + getImplicitPortrait(id).then((res) => { + const response = res as PortraitItem + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( + + {loading + ? + :
+ {(['aesthetic', 'creativity', 'literature', 'technology'] as const).map((key) => { + const item = data[key] as Item + return ( +
+
+
{t(`implicitDetail.${key}`)}
+ {item?.percentage ?? 0}% +
+ +
+ ) + })} +
+ } +
+ ) +} +export default Portrait \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/Preferences.tsx b/web/src/views/UserMemoryDetail/components/Preferences.tsx new file mode 100644 index 00000000..3d1372de --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/Preferences.tsx @@ -0,0 +1,183 @@ +import { type FC, useEffect, useState, useRef, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Row, Col, Skeleton } from 'antd' +import * as echarts from 'echarts' +import 'echarts-wordcloud' + +import Empty from '@/components/Empty' +import RbCard from '@/components/RbCard/Card' +import { getImplicitPreferences } from '@/api/memory' + +interface PreferenceItem { + tag_name: string; + confidence_score: number; + supporting_evidence: string[]; + context_details: string; + created_at: number | string; // TODO + updated_at: number | string; // TODO + conversation_references: string[]; + category: string; +} + +const DEFAULT_COLORS = ['#FF5D34', '#155EEF', '#9C6FFF', '#369F21', '#4DA8FF', '#FF8C00', '#32CD32', '#FF69B4', '#20B2AA', '#DDA0DD'] + +const generateCategoryColors = (categories: string[]) => { + const colors: Record = {} + categories.forEach((category, index) => { + colors[category] = DEFAULT_COLORS[index % DEFAULT_COLORS.length] + }) + return colors +} + +const Preferences: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const chartRef = useRef(null) + const chartInstance = useRef(null) + const [selectedWord, setSelectedWord] = useState(null) + const [loading, setLoading] = useState(false) + const [data, setData] = useState([]) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + const getData = () => { + if (!id) { + return + } + setLoading(true) + setSelectedWord(null) + getImplicitPreferences(id) + .then((res) => { + setData(res as PreferenceItem[]) + }) + .finally(() => { + setLoading(false) + }) + } + + const uniqueCategories = [...new Set(data.map(item => item.category).filter(Boolean))] + const categoryColors = generateCategoryColors(uniqueCategories) + + const getCategoryColor = (category: string) => { + return categoryColors[category] || '#4DA8FF' + } + + useEffect(() => { + if (!chartRef.current || !data.length) return + + if (chartInstance.current) { + chartInstance.current.dispose() + } + + chartInstance.current = echarts.init(chartRef.current) + + const wordCloudData = data.map((item, index) => ({ + name: item.tag_name, + value: Math.round(item.confidence_score * 100), + itemIndex: index, + textStyle: { + color: getCategoryColor(item.category) + } + })) + + const option = { + series: [{ + type: 'wordCloud', + gridSize: 8, + sizeRange: [14, 60], + rotationRange: [-45, 45], + shape: 'pentagon', + width: '100%', + height: '100%', + textStyle: { + fontFamily: 'sans-serif', + fontWeight: 'bold' + }, + emphasis: { + textStyle: { + shadowBlur: 10, + shadowColor: '#333' + } + }, + data: wordCloudData + }] + } + + chartInstance.current.setOption(option) + + chartInstance.current.on('click', (params) => { + const clickedIndex = (params.data as any).itemIndex + if (selectedWord !== clickedIndex) { + setSelectedWord(clickedIndex) + } + + // Highlight selected word without redrawing + chartInstance.current?.dispatchAction({ + type: 'highlight', + dataIndex: clickedIndex + }) + }) + + return () => { + if (chartInstance.current) { + chartInstance.current.dispose() + chartInstance.current = null + } + } + }, [data]) + + + console.log(selectedWord, data) + + const detailTitle = useMemo(() => { + return selectedWord !== null && data[selectedWord].tag_name ? <>{data[selectedWord].tag_name}{t('implicitDetail.preferencesDetail')} : '' + }, [selectedWord, data, t]) + + return ( + <> +
{t('forgetDetail.overviewTitle')}
+ + + + {loading + ? + : data && data.length > 0 + ?
+ : + } + + + + + {selectedWord === null + ? + : <> +
{t('implicitDetail.context_details')}
+
{data[selectedWord].context_details}
+ +
{t('implicitDetail.supporting_evidence')}
+ {data[selectedWord].supporting_evidence.map((vo, index) =>
-{vo}
)} + + } +
+ + + + ) +} + +export default Preferences \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/RecentTrendsLineCard.tsx b/web/src/views/UserMemoryDetail/components/RecentTrendsLineCard.tsx new file mode 100644 index 00000000..1d6bd213 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/RecentTrendsLineCard.tsx @@ -0,0 +1,191 @@ +import { type FC, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import ReactEcharts from 'echarts-for-react'; +import * as echarts from 'echarts'; +import Empty from '@/components/Empty' +import Loading from '@/components/Empty/Loading' +import RbCard from '@/components/RbCard/Card' + +interface RecentTrendsLineCardProps { + chartData: Array>; + seriesList: string[]; + loading?: boolean; +} + +const Colors = ['#155EEF', '#FF5D34'] + +const RecentTrendsLineCard: FC = ({ chartData, seriesList, loading }) => { + const { t } = useTranslation() + const chartRef = useRef(null); + + const getSeries = () => { + return seriesList.map((key, index) => ({ + name: key === 'merged_count' ? t('forgetDetail.merged_count') : t('forgetDetail.average_activation'), + type: 'line', + yAxisIndex: key === 'merged_count' ? 0 : 1, + smooth: true, + lineStyle: { + width: 3, + color: Colors[index] + }, + itemStyle: { + color: Colors[index] + }, + areaStyle: { + color: Colors[index], + opacity: 0.08 + }, + data: chartData.map(item => item[key]) + })) + } + + return ( + + {loading + ? + : !chartData || chartData.length === 0 + ? + : ` + params.forEach((param: any) => { + result += `${param.marker}${param.seriesName}: ${param.value}
` + }) + return result + } + }, + legend: { + bottom: 2, + padding: 0, + itemGap: 24, + itemWidth: 40, + itemHeight: 12, + borderRadius: 2, + orient: 'horizontal', + textStyle: { + color: '#5B6167', + fontFamily: 'PingFangSC, PingFang SC', + lineHeight: 16, + } + }, + grid: { + top: 16, + left: 30, + right: 36, + bottom: 48, + // containLabel: false + }, + xAxis: { + type: 'category', + data: chartData.map(item => item.date), + boundaryGap: false, + axisLabel: { + color: '#A8A9AA', + fontFamily: 'PingFangSC, PingFang SC' + }, + axisLine: { + show: true, + lineStyle: { + color: '#EBEBEB' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#EBEBEB', + type: 'solid' + } + }, + axisTick: { + show: true, + lineStyle: { + color: '#EBEBEB', + type: 'solid' + } + } + }, + yAxis: [ + { + type: 'value', + position: 'left', + axisLabel: { + color: Colors[0], + fontFamily: 'PingFangSC, PingFang SC' + }, + axisLine: { + lineStyle: { + color: Colors[0] + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#EBEBEB', + type: 'solid' + } + }, + axisTick: { + show: true, + lineStyle: { + color: '#EBEBEB', + type: 'solid' + } + }, + }, + { + type: 'value', + position: 'right', + axisLabel: { + color: Colors[1], + fontFamily: 'PingFangSC, PingFang SC', + formatter: '{value}' + }, + axisLine: { + lineStyle: { + color: Colors[1] + } + }, + splitLine: { + show: false, + }, + axisTick: { + show: true, + lineStyle: { + color: '#EBEBEB', + type: 'solid' + } + }, + max: 1, + min: 0 + } + ], + series: getSeries() + }} + style={{ height: '265px', width: '100%', minWidth: '100%' }} + notMerge={true} + lazyUpdate={true} + /> + } +
+ ) +} + +export default RecentTrendsLineCard diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index db7c9e57..4cce1100 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -1,17 +1,18 @@ import React, { type FC, useEffect, useState, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' -import { Col, Row } from 'antd' +import { Col, Row, Space, Button } from 'antd' import dayjs from 'dayjs' import RbCard from '@/components/RbCard/Card' import ReactEcharts from 'echarts-for-react' import detailEmpty from '@/assets/images/userMemory/detail_empty.png' -import type { Node, Edge, GraphData } from '../types' +import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types' import { getMemorySearchEdges, } from '@/api/memory' import Empty from '@/components/Empty' +import Tag from '@/components/Tag' const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048'] const RelationshipNetwork:FC = () => { @@ -136,6 +137,11 @@ const RelationshipNetwork:FC = () => { console.log('selectedNode', selectedNode) + const handleViewAll = () => { + if (!selectedNode) return + window.open(`/#/graph/${selectedNode.id}`); + } + return ( {/* 关系网络 */} @@ -240,8 +246,14 @@ const RelationshipNetwork:FC = () => { title={t('userMemory.memoryDetails')} headerType="borderless" bodyClassName='rb:p-0!' + extra={selectedNode && } > -
+
{!selectedNode ? {
{t('userMemory.created_at')}
-
+
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
+ + {selectedNode?.properties.associative_memory > 0 &&
+
{t('userMemory.associative_memory')}
+
+ {selectedNode?.properties.associative_memory} {t('userMemory.unix')}{t('userMemory.associative_memory')} +
+
} + + {selectedNode.label === 'Statement' && <> + {(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => { + const statementProps = selectedNode.properties as StatementNodeProperties; + if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || statementProps[key]) { + return ( +
+ {t(`userMemory.Statement_${key}`)} +
+ {key === 'emotion_keywords' + ? {statementProps.emotion_keywords.map((vo, index) => {vo})} + : statementProps[key] + } +
+
+ ) + } + return null + })} + } + {selectedNode.label === 'ExtractedEntity' && <> + {(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => { + const entityProps = selectedNode.properties as ExtractedEntityNodeProperties; + if (entityProps[key]) { + return ( +
+ {t(`userMemory.ExtractedEntity_${key}`)} +
+ {entityProps[key]} +
+
+ ) + } + return null + })} + }
diff --git a/web/src/views/UserMemoryDetail/components/Timeline.tsx b/web/src/views/UserMemoryDetail/components/Timeline.tsx new file mode 100644 index 00000000..d7b9b273 --- /dev/null +++ b/web/src/views/UserMemoryDetail/components/Timeline.tsx @@ -0,0 +1,82 @@ +import { type FC, useEffect, useState } from 'react' +import clsx from 'clsx' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Skeleton, Progress, Space, Tooltip, Divider } from 'antd'; +import RbCard from '@/components/RbCard/Card' +import { + getPerceptualTimeline +} from '@/api/memory' +import { formatDateTime } from '@/utils/format'; +import Empty from '@/components/Empty' + +interface TimelineItem { + id: string; + perceptual_type: number; + file_path: string; + file_name: string; + summary: string; + storage_type: number; + created_time: string | number; +} + +const KEYS = { + last_visual: ['summary', 'keywords', 'topic', 'domain', 'scene'], + last_listen: ['summary', 'keywords', 'topic', 'domain', 'speaker_count'], + last_text: ['summary', 'keywords', 'topic', 'domain', 'section_count'], +} + +const perceptual_type: Record = { + 1: 'last_visual', + 2: 'last_listen', + 3: 'last_text', +} + +const Timeline: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState([]) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + const getData = () => { + if (!id) return + setLoading(true) + getPerceptualTimeline(id).then((res) => { + const response = res as { memories: TimelineItem[] } + setData(response.memories || []) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( + + {loading + ? + : data.length === 0 + ? + : + {data.map((vo, index) => ( +
+
+ {formatDateTime(vo.created_time)} + {index !== data.length - 1 && } +
+
+
{vo.summary}
+
{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}
+
+
+ ))} +
+ } +
+ ) +} +export default Timeline \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/WordCloud.tsx b/web/src/views/UserMemoryDetail/components/WordCloud.tsx index 64f2bfaa..b249a03a 100644 --- a/web/src/views/UserMemoryDetail/components/WordCloud.tsx +++ b/web/src/views/UserMemoryDetail/components/WordCloud.tsx @@ -114,7 +114,7 @@ const WordCloud: FC = () => {
{t(`statementDetail.${item.emotion_type}`)} -
{item.count}{t('statementDetail.pieces')}
+
{item.count} {t('statementDetail.pieces')}
diff --git a/web/src/views/UserMemoryDetail/pages/EpisodicDetail.tsx b/web/src/views/UserMemoryDetail/pages/EpisodicDetail.tsx new file mode 100644 index 00000000..4a7e4b1f --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/EpisodicDetail.tsx @@ -0,0 +1,250 @@ +import { type FC, useEffect, useState } from 'react' +import clsx from 'clsx' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Row, Col, Select, Form, Space, Skeleton, Input } from 'antd' +import RbCard from '@/components/RbCard/Card' +import { + getEpisodicOverview, + getEpisodicDetail, +} from '@/api/memory' +import { formatDateTime } from '@/utils/format' +import Tag from '@/components/Tag' +import RbAlert from '@/components/RbAlert' +import Empty from '@/components/Empty' + +interface EpisodicMemory { + id: string; + title: string; + type: string; + created_at: number; +} +interface EpisodicOverviewData { + total: number; + total_all: number; + episodic_memories: EpisodicMemory[] +} +interface EpisodicMemoryDetail { + id: string; + created_at: number; + involved_objects: string[]; + episodic_type: string; + content_records: string[]; + emotion: string; +} + +const TAG_COLORS: Record = { + conversation: "processing", + project_work: "success", + learning: "warning", + decision: "warning", + important_event: "error", +} +const BG_COLORS: Record = { + conversation: "rb:bg-[#155EEF]", + project_work: "rb:bg-[#369F21]", + learning: "rb:bg-[#FF5D34]", + decision: "rb:bg-[#FF5D34]", + important_event: "rb:bg-[#5B6167]", +} + +// Map display types to internal keys +const getTypeKey = (type: string): string => { + const typeMap: Record = { + 'Learning': 'learning', + 'Project/Work': 'project_work', + 'Conversation': 'conversation', + 'Decision': 'decision', + 'Important Event': 'important_event', + } + return typeMap[type] || type.toLowerCase().replace(/[^a-z0-9]/g, '_') +} +const EpisodicDetail: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as EpisodicOverviewData) + const values = Form.useWatch([], form) + const [detailLoading, setDetailLoading] = useState(false) + const [detail, setDetail] = useState(null) + const [selected, setSelected] = useState(null) + + useEffect(() => { + if (!id) return + // getData() + }, [id]) + + // 记忆洞察 + const getData = () => { + if (!id) return + setLoading(true) + setSelected(null) + setDetail(null) + getEpisodicOverview({ + end_user_id: id, + ...values + }).then((res) => { + const response = res as EpisodicOverviewData + setData(response) + if (response.episodic_memories.length > 0) { + setSelected(response.episodic_memories[0]) + } + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + useEffect(() => { + getData() + }, [values]) + + useEffect(() => { + getDetail() + }, [selected]) + + const getDetail = () => { + if (!selected || !selected.id) return + + setDetailLoading(true) + getEpisodicDetail({ + end_user_id: id as string, + summary_id: selected.id + }) + .then(res => { + setDetail(res as EpisodicMemoryDetail) + }) + .finally(() => { + setDetailLoading(false) + }) + } + + return ( +
+
+
{t('episodicDetail.title')}
+ +
+
+
{data.total_all ?? 0}
+ {t(`episodicDetail.total_all`)} +
+
+
+ + + + + + + + + + + + + + + + + + + {t('episodicDetail.curResult')} ({data.total || 0}{t('episodicDetail.unix')})} + headerType="borderless" + > + {loading + ? + : !data.episodic_memories || data.episodic_memories.length === 0 + ? + : ( + + {data.episodic_memories.map((vo, index) => ( +
setSelected(vo)} + > +
{index + 1}
+
+
{vo.title} {t(`episodicDetail.${getTypeKey(vo.type)}`)}
+
{formatDateTime(vo.created_at)}
+
+
+ ))} +
+ ) + } + +
+ + + + {detailLoading + ? + : !selected || !detail + ? + : ( + +
+ + +
{t('episodicDetail.created')}
{formatDateTime(detail.created_at)}
+ + +
{t('episodicDetail.episodic_type')}
{detail.episodic_type}
+ + {detail.involved_objects.length > 0 && +
{t('episodicDetail.involved_objects')}
+ {detail.involved_objects.map((vo, index) => {vo})} + } +
+
+
+
{t('episodicDetail.content_records')}
+ {detail.content_records.map((vo, index) =>
- {vo}
)} +
+ + {t('episodicDetail.emotion')}: {t(`statementDetail.${detail.emotion}`)} + +
+ ) + } +
+ +
+
+ ) +} +export default EpisodicDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx b/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx new file mode 100644 index 00000000..f0ba04ff --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/ForgetDetail.tsx @@ -0,0 +1,159 @@ +import { type FC, useEffect, useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Row, Col, Progress } from 'antd' +import RbCard from '@/components/RbCard/Card' +import { + getForgetStats, +} from '@/api/memory' +import type { ForgetData } from '../types' +import ActivationMetricsPieCard from '../components/ActivationMetricsPieCard' +import RecentTrendsLineCard from '../components/RecentTrendsLineCard' +import Table from '@/components/Table' +import { formatDateTime } from '@/utils/format' +import StatusTag from '@/components/StatusTag' + +const statusTagColors: Record = { + statement: 'success', + entity: 'purple', + summary: 'default', + chunk: 'warning', +} + +const ForgetDetail: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as ForgetData) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + // 记忆洞察 + const getData = () => { + if (!id) return + setLoading(true) + getForgetStats(id).then((res) => { + const response = res as ForgetData + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + const chartData = useMemo(() => { + const { activation_metrics } = data + if (!activation_metrics) return [] + + let health_nodes = (activation_metrics.total_nodes || 0) - (activation_metrics.low_activation_nodes || 0) - (activation_metrics.nodes_without_activation || 0) + + return [ + { name: t('forgetDetail.health_nodes'), value: health_nodes }, + { name: t('forgetDetail.nodes_without_activation'), value: activation_metrics.nodes_without_activation || 0 }, + { name: t('forgetDetail.low_activation_nodes'), value: activation_metrics.low_activation_nodes || 0 }, + ] + + }, [data.activation_metrics, t]) + + const seriesList = useMemo(() => { + const { recent_trends = [] } = data + if (!recent_trends || recent_trends.length === 0) return { chartData: [], seriesList: [] } + + return { + chartData: recent_trends, + seriesList: ['merged_count', 'average_activation'] + } + }, [data.recent_trends]) + + return ( +
+
{t('forgetDetail.title')}
+
{t('forgetDetail.overviewTitle')}
+ + + +
{t('forgetDetail.totalMemory')}
+
{data?.activation_metrics?.total_nodes ?? 0}
+
+ {['statement_count', 'entity_count', 'summary_count', 'chunk_count'].map((key, index) => ( +
+
{data?.node_distribution?.[key as keyof typeof data.node_distribution] ?? 0}
+
{t(`forgetDetail.${key}`)}
+
+ ))} +
+
+ + + +
{t('forgetDetail.MemoryHealth')}
+
{data?.activation_metrics?.average_activation_value ?? 0}
+ +
+
{t('forgetDetail.healthStatus')}
+
{data?.activation_metrics?.average_activation_value > data.activation_metrics?.forgetting_threshold ? t('forgetDetail.healthy') : t('forgetDetail.unhealthy')}
+
+ {t('forgetDetail.average')}
+ {t('forgetDetail.threshold')}{data.activation_metrics?.forgetting_threshold ?? 0} +
+
+
+ + + +
{t('forgetDetail.riskOfForgetting')}
+
{data.activation_metrics?.low_activation_nodes ?? 0}
+
{t('forgetDetail.low_nodes')}
+
+ +
+ +
{t('forgetDetail.memoryHealthVisualization')}
+ + + + + + + + +
{t('forgetDetail.pending_nodes')}
+
{content_summary}
+ }, + { + title: t('forgetDetail.node_type'), + dataIndex: 'node_type', + key: 'node_type', + render: (node_type: string) => { + return } + }, + { + title: t('forgetDetail.last_access_time'), + dataIndex: 'last_access_time', + key: 'last_access_time', + render: (last_access_time) => formatDateTime(last_access_time, 'YYYY-MM-DD HH:mm') + }, + { + title: t('forgetDetail.activation_value'), + dataIndex: 'activation_value', + key: 'activation_value', + }, + ]} + pagination={false} + /> + + ) +} +export default ForgetDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx new file mode 100644 index 00000000..f3cf716c --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx @@ -0,0 +1,14 @@ +import { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Row, Col } from 'antd' + +const GraphDetail: FC = () => { + const { t } = useTranslation() + + return ( +
+ GraphDetail +
+ ) +} +export default GraphDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx b/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx new file mode 100644 index 00000000..ef23463a --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/ImplicitDetail.tsx @@ -0,0 +1,34 @@ +import { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Row, Col } from 'antd' + +import Preferences from '../components/Preferences' +import Portrait from '../components/Portrait' +import InterestAreas from '../components/InterestAreas' +import Habits from '../components/Habits' + +const ImplicitDetail: FC = () => { + const { t } = useTranslation() + + return ( +
+
{t('implicitDetail.title')}
+ + + +
{t('implicitDetail.portraitTitle')}
+
{t('implicitDetail.portraitSubTitle')}
+ +
+ + + + + + + + + + ) +} +export default ImplicitDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/PerceptualDetail.tsx b/web/src/views/UserMemoryDetail/pages/PerceptualDetail.tsx new file mode 100644 index 00000000..7e2d5353 --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/PerceptualDetail.tsx @@ -0,0 +1,32 @@ +import { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Row, Col } from 'antd' + +import PerceptualLastInfo from '../components/PerceptualLastInfo' +import Timeline from '../components/Timeline' + +const PerceptualDetail: FC = () => { + const { t } = useTranslation() + + return ( +
+
{t('perceptualDetail.lastInfo')}
+ + +
+ + + + + + + + + + +
{t('perceptualDetail.timeLine')}
+ + + ) +} +export default PerceptualDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/ShortTermDetail.tsx b/web/src/views/UserMemoryDetail/pages/ShortTermDetail.tsx new file mode 100644 index 00000000..6cc8eafc --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/ShortTermDetail.tsx @@ -0,0 +1,114 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Space, Skeleton } from 'antd' +import { + getShortTerm, +} from '@/api/memory' +import Empty from '@/components/Empty' + +interface ShortTermItem { + retrieval: Array<{ query: string; retrieval: string[]; }>; + message: string; + answer: string; +} +interface LongTermItem { + query: string; + retrieval: string; +} +interface ShortData { + short_term: ShortTermItem[]; + long_term: LongTermItem[]; + entity: number; + retrieval_number: number; + long_term_number: number; +} +const ShortTermDetail: FC = () => { + const { t } = useTranslation() + const { id } = useParams() + const [loading, setLoading] = useState(false) + const [data, setData] = useState({} as ShortData) + + useEffect(() => { + if (!id) return + getData() + }, [id]) + + const getData = () => { + if (!id) return + setLoading(true) + getShortTerm(id).then((res) => { + const response = res as ShortData + setData(response) + setLoading(false) + }) + .finally(() => { + setLoading(false) + }) + } + + return ( +
+
+
{t('shortTermDetail.title')}
+ +
+ {(['retrieval_number', 'entity', 'long_term_number'] as const).map(key => ( +
+
{(data as any)[key] ?? 0}
+ {t(`shortTermDetail.${key}`)} +
+ ))} +
+
+ + +
{t('shortTermDetail.shortTermTitle')}
+
{t('shortTermDetail.shortTermSubTitle')}
+ + {loading + ? + : !data.short_term || data.short_term.length === 0 + ? + :data.short_term?.map((vo, voIdx) => ( +
+
{vo.message}
+ + {vo.retrieval.map((item, index) => ( +
+
{t('shortTermDetail.query')}: {item.query}
+
{t('shortTermDetail.answer')}:
+ {item.retrieval.length > 0 ? item.retrieval.map((retrieval, retrievalIdx) => ( +
- {retrieval}
+ )) :
{t('shortTermDetail.noAnswer')}
} +
+ ))} +
+
{t('shortTermDetail.answer')}
+
{vo.answer}
+
+
+
+ )) + } +
+ +
{t('shortTermDetail.longTermTitle')}
+
{t('shortTermDetail.shortTermSubTitle')}
+ + {loading + ? + : !data.long_term || data.long_term.length === 0 + ? + : data.long_term?.map((vo, voIdx) => ( +
+
{vo.query}
+
{vo.retrieval}
+
+ )) + } +
+
+ ) +} +export default ShortTermDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx index 744c244d..e6ddfd20 100644 --- a/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/StatementDetail.tsx @@ -1,53 +1,26 @@ -import { type FC, useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' +import { type FC } from 'react' import { Row, Col, Space } from 'antd'; import WordCloud from '../components/WordCloud' import EmotionTags from '../components/EmotionTags' import Health from '../components/Health' import Suggestions from '../components/Suggestions' -import PageHeader from '../components/PageHeader' -import { - getEndUserProfile, -} from '@/api/memory' const StatementDetail: FC = () => { - const { id } = useParams() - const [name, setName] = useState('') - useEffect(() => { - if (!id) return - getData() - }, [id]) - - const getData = () => { - if (!id) return - getEndUserProfile(id).then((res) => { - const response = res as { other_name: string; id: string; } - setName(response.other_name ?? response.id) - }) - } return ( -
- -
- -
- - - - - - - - - - - - + + + + + + + + + + + + ) } diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx new file mode 100644 index 00000000..da62c14e --- /dev/null +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -0,0 +1,70 @@ +import { type FC, useEffect, useState, useMemo } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Dropdown } from 'antd' + +import PageHeader from '../components/PageHeader' +import StatementDetail from './StatementDetail' +import ForgetDetail from './ForgetDetail' +import ImplicitDetail from './ImplicitDetail' +import ShortTermDetail from './ShortTermDetail' +import PerceptualDetail from './PerceptualDetail' +import EpisodicDetail from './EpisodicDetail' +import { + getEndUserProfile, +} from '@/api/memory' + +const Detail: FC = () => { + const { t } = useTranslation() + const { id, type } = useParams() + const navigate = useNavigate() + const [name, setName] = useState('') + useEffect(() => { + if (!id) return + getData() + }, [id]) + + const getData = () => { + if (!id) return + getEndUserProfile(id).then((res) => { + const response = res as { other_name: string; id: string; } + setName(response.other_name || response.id) + }) + } + const items = useMemo(() => { + return ['PERCEPTUAL_MEMORY', 'WORKING_MEMORY', 'EMOTIONAL_MEMORY', 'SHORT_TERM_MEMORY', 'IMPLICIT_MEMORY', 'EPISODIC_MEMORY', 'EXPLICIT_MEMORY', 'FORGETTING_MANAGEMENT'] + .map(key => ({ key, label: t(`userMemory.${key}`) })) + }, [t]) + const onClick = ({ key }: { key: string }) => { + navigate(`/user-memory/detail/${id}/${key}`, { replace: true }) + } + + return ( +
+ +
+ - {type ? t(`userMemory.${type}`) : ''} +
+
+ + } + /> +
+ {type === 'EMOTIONAL_MEMORY' && } + {type === 'FORGETTING_MANAGEMENT' && } + {type === 'IMPLICIT_MEMORY' && } + {type === 'SHORT_TERM_MEMORY' && } + {type === 'PERCEPTUAL_MEMORY' && } {/** TODO */} + {type === 'EPISODIC_MEMORY' && } +
+
+ ) +} + +export default Detail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index 8fd050a9..263494d0 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -44,6 +44,7 @@ export interface Data { export interface BaseProperties { content: string; created_at: number; + associative_memory: number; } export interface StatementNodeProperties { temporal_info: string; @@ -51,12 +52,21 @@ export interface StatementNodeProperties { statement: string; valid_at: string; created_at: number; + emotion_keywords: string[]; + emotion_type: string; + emotion_subject: string; + importance_score: number; + associative_memory: number; } export interface ExtractedEntityNodeProperties { description: string; name: string; entity_type: string; created_at: number; + aliases: string; + connect_strngth: string; + importance_score: number; + associative_memory: number; } export interface MemorySummaryNode { id: string; @@ -72,7 +82,7 @@ export interface MemorySummaryNode { created_at: number; } caption: string; - + associative_memory: number; } export interface Node { @@ -140,4 +150,38 @@ export interface AboutMeRef { } export interface EndUserProfileRef { data: EndUser | null +} + + +export interface ForgetData { + activation_metrics: { + total_nodes: number; + nodes_with_activation: number; + nodes_without_activation: number; + average_activation_value: number; + low_activation_nodes: number; + timestamp: number; + forgetting_threshold: number; + }, + node_distribution: { + statement_count: number; + entity_count: number; + summary_count: number; + chunk_count: number; + }, + recent_trends: { + date: string; + merged_count: number; + average_activation: number; + total_nodes: number; + execution_time: number; + }[], + pending_nodes: { + node_id: string; + node_type: string; + content_summary: string; + activation_value: number; + last_access_time: number; + }[], + timestamp: number; } \ No newline at end of file