feat(web): user memory detail

This commit is contained in:
zhaoying
2026-01-08 19:46:02 +08:00
parent a1e8d858a2
commit 81508a25a8
28 changed files with 1463 additions and 31 deletions

View File

@@ -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';

View File

@@ -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<boolean>(false)
const [data, setData] = useState<HabitsItem[]>([])
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 (
<>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('implicitDetail.habits')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('implicitDetail.habitsSubTitle')}</div>
<RbCard>
{loading
? <Skeleton active />
: data.length === 0
? <Empty size={88} />
: <Space size={12} direction="vertical" className="rb:w-full!">
{data.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3">
<div className="rb:flex rb:items-center rb:justify-between">
<div>
<div className="rb:mb-1">{vo.habit_description}</div>
<div className="rb:mb-1 rb:text-[#5B6167]">{vo.time_context}</div>
</div>
<div className="rb:text-[24px] rb:font-medium">{vo.confidence_level}%</div>
</div>
{vo.specific_examples.length > 0 && <>
<div className="rb:mt-3 rb:mb-2">{t('implicitDetail.specific_examples')}</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3">
{vo.specific_examples.map((item, index) => (
<div key={index} className="rb:text-[#5B6167] rb:text-[12px] rb:mt-1">- {item}</div>
))}
</div>
</>}
<Progress percent={vo.confidence_level} showInfo={false} className="rb:mt-3" />
</div>
))}
</Space>
}
</RbCard>
</>
)
}
export default Habits

View File

@@ -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<boolean>(false)
const [data, setData] = useState<InterestAreasItem>({} 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 (
<RbCard
title={t('implicitDetail.interestAreas')}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div>
{(['art', 'music', 'tech', 'lifestyle'] as const).map((key) => {
return (
<div key={key} >
<div className="rb:flex rb:justify-between rb:items-center">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-1">{t(`implicitDetail.${key}`)}</div>
{data[key]?.percentage ?? 0}%
</div>
<Progress percent={data[key]?.percentage || 0} showInfo={false} />
</div>
)
})}
</div>
}
</RbCard>
)
}
export default InterestAreas

View File

@@ -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<boolean>(false)
const [data, setData] = useState<PerceptualLastInfoItem>({} 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 (
<RbCard
title={t(`perceptualDetail.${type}`)}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div>
<div className="rb:bg-[#F0F3F8] rb:h-36 rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:overflow-hidden">
{data.file_path ? (
type === 'last_visual' ? (
/\.(mp4|webm|ogg|mov)$/i.test(data.file_name) ? (
<video controls className="rb:max-w-full rb:max-h-full">
<source src={data.file_path} />
</video>
) : /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(data.file_name) ? (
<Image src={data.file_path} alt={data.file_name} />
// <img src={data.file_path} alt={data.file_name} className="rb:max-w-full rb:max-h-full rb:object-contain" />
) : (
<div className="rb:text-gray-500">{data.file_name}</div>
)
) : type === 'last_listen' && /\.(mp3|wav|ogg|m4a|aac)$/i.test(data.file_name) ? (
<audio controls className="rb:w-full">
<source src={data.file_path} />
</audio>
) : (
<div className="rb:text-gray-500">{data.file_name}</div>
)
) : (
<div className="rb:text-gray-400">No file</div>
)}
</div>
<Space size={4} direction="vertical" className="rb:w-full rb:mt-3">
{KEYS[type].map(key => {
const value = (data as any)[key]
return (
<div key={key} className="rb:flex rb:justify-between rb:items-center rb:gap-3">
<div className="rb:text-[#5B6167]">{t(`perceptualDetail.${key}`)}</div>
{key === 'summary' ? (
<Tooltip title={value}>
<div className="rb:flex-1 rb:text-right rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'}
</div>
</Tooltip>
)
: <div className="rb:flex-1 rb:text-right">
{typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'}
</div>
}
</div>
)
})}
</Space>
</div>
}
</RbCard>
)
}
export default PerceptualLastInfo

View File

@@ -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<boolean>(false)
const [data, setData] = useState<PortraitItem>({} 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 (
<RbCard
title={t('implicitDetail.portrait')}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div className="rb:mt-1">
{(['aesthetic', 'creativity', 'literature', 'technology'] as const).map((key) => {
const item = data[key] as Item
return (
<div key={key}>
<div className="rb:flex rb:justify-between rb:items-center">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-1">{t(`implicitDetail.${key}`)}</div>
{item?.percentage ?? 0}%
</div>
<Progress percent={item?.percentage || 0} showInfo={false} />
</div>
)
})}
</div>
}
</RbCard>
)
}
export default Portrait

View File

@@ -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<string, string> = {}
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<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
const [selectedWord, setSelectedWord] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [data, setData] = useState<PreferenceItem[]>([])
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 (
<>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-4 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('forgetDetail.overviewTitle')}</div>
<Row gutter={16}>
<Col span={16}>
<RbCard
title={t('implicitDetail.preferences')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName='rb:p-0! rb:pb-3! rb:relative rb:h-[350px]'
>
{loading
? <Skeleton active className="rb:px-4" />
: data && data.length > 0
? <div ref={chartRef} className="rb:mt-6 rb:px-6" style={{ height: '350px' }} />
: <Empty size={88} className="rb:h-full" />
}
</RbCard>
</Col>
<Col span={8}>
<RbCard
title={detailTitle}
headerType="borderless"
height="100%"
bodyClassName='rb:p-3! rb:h-[326px]'
>
{selectedWord === null
? <Empty size={88} className="rb:h-full!" />
: <>
<div className="rb:leading-5 rb:mb-1 rb:font-medium">{t('implicitDetail.context_details')}</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">{data[selectedWord].context_details}</div>
<div className="rb:leading-5 rb:mt-3 rb:font-medium">{t('implicitDetail.supporting_evidence')}</div>
{data[selectedWord].supporting_evidence.map((vo, index) => <div key={index} className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">-{vo}</div>)}
</>
}
</RbCard>
</Col>
</Row>
</>
)
}
export default Preferences

View File

@@ -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 (
<Row gutter={16}>
{/* 关系网络 */}
@@ -240,8 +246,14 @@ const RelationshipNetwork:FC = () => {
title={t('userMemory.memoryDetails')}
headerType="borderless"
bodyClassName='rb:p-0!'
extra={selectedNode && <Button type="text" onClick={handleViewAll}>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/view.svg')] rb:hover:bg-[url('@/assets/images/userMemory/view_hover.svg')]"
></div>
{t('userMemory.completeMemory')}
</Button>}
>
<div className="rb:h-133.5">
<div className="rb:h-133.5 rb:overflow-y-auto">
{!selectedNode
? <Empty
url={detailEmpty}
@@ -267,9 +279,52 @@ const RelationshipNetwork:FC = () => {
</>
<div className="rb:font-medium rb:mb-2 rb:mt-4">
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4">
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
{selectedNode?.properties.associative_memory > 0 && <div className="rb:mt-4">
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
</div>
</div>}
{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 (
<div className="rb:mt-4" key={key}>
{t(`userMemory.Statement_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{key === 'emotion_keywords'
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
: statementProps[key]
}
</div>
</div>
)
}
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 (
<div className="rb:mt-4" key={key}>
{t(`userMemory.ExtractedEntity_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{entityProps[key]}
</div>
</div>
)
}
return null
})}
</>}
</div>
</div>
</>

View File

@@ -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<number, string> = {
1: 'last_visual',
2: 'last_listen',
3: 'last_text',
}
const Timeline: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<TimelineItem[]>([])
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 (
<RbCard>
{loading
? <Skeleton active />
: data.length === 0
? <Empty />
: <Space size={8} direction="vertical" className="rb:w-full">
{data.map((vo, index) => (
<div key={vo.id} className="rb:flex rb:gap-6 rb:min-h-16">
<div className="rb:text-[#155EEF] rb:leading-5 rb:font-medium rb:flex rb:flex-col rb:gap-2 rb:items-center">
{formatDateTime(vo.created_time)}
{index !== data.length - 1 && <Divider type="vertical" className="rb:flex-1 rb:w-px rb:border-[#155EEF]!" />}
</div>
<div className="rb:flex rb:justify-between rb:flex-1 rb:mb-4">
<div className="rb:w-150 rb:leading-5">{vo.summary}</div>
<div className="rb:text-[#5B6167] rb:font-medium">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div>
</div>
</div>
))}
</Space>
}
</RbCard>
)
}
export default Timeline

View File

@@ -114,7 +114,7 @@ const WordCloud: FC = () => {
<div key={item.emotion_type}>
<div className="rb:flex rb:items-center rb:justify-between rb:font-medium">
{t(`statementDetail.${item.emotion_type}`)}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.count}{t('statementDetail.pieces')}</div>
<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>

View File

@@ -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<string, "processing" | "success" | "warning" | "error" | "default"> = {
conversation: "processing",
project_work: "success",
learning: "warning",
decision: "warning",
important_event: "error",
}
const BG_COLORS: Record<string, string> = {
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<string, string> = {
'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<boolean>(false)
const [data, setData] = useState<EpisodicOverviewData>({} as EpisodicOverviewData)
const values = Form.useWatch([], form)
const [detailLoading, setDetailLoading] = useState<boolean>(false)
const [detail, setDetail] = useState<EpisodicMemoryDetail | null>(null)
const [selected, setSelected] = useState<EpisodicMemory | null>(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 (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:flex rb:justify-between rb:items-center rb:text-[#FFFFFF] rb:leading-5 rb:h-30 rb:p-5 rb:bg-[url('@/assets/images/userMemory/shortTerm.png')] rb:bg-cover rb:mb-6">
<div className="rb:max-w-135">{t('episodicDetail.title')}</div>
<div className="rb:grid rb:grid-cols-1 rb:gap-4">
<div className="rb:bg-[rgba(255,255,255,0.2)] rb:rounded-lg rb:p-3.5 rb:text-[12px] rb:text-center">
<div className="rb:text-[24px] rb:leading-8 rb:mb-1">{data.total_all ?? 0}</div>
{t(`episodicDetail.total_all`)}
</div>
</div>
</div>
<Form form={form} initialValues={{ time_range: 'all', episodic_type: 'all' }}>
<Row gutter={16}>
<Col span={6}>
<Form.Item name="time_range">
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: 'all', label: t('episodicDetail.all') },
{ value: 'today', label: t('episodicDetail.today') },
{ value: 'this_week', label: t('episodicDetail.this_week') },
{ value: 'this_month', label: t('episodicDetail.this_month') },
]}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="episodic_type">
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: 'all', label: t('episodicDetail.all') },
{ value: 'conversation', label: t('episodicDetail.conversation') },
{ value: 'project_work', label: t('episodicDetail.project_work') },
{ value: 'learning', label: t('episodicDetail.learning') },
{ value: 'decision', label: t('episodicDetail.decision') },
{ value: 'important_event', label: t('episodicDetail.important_event') },
]}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="title_keyword">
<Input placeholder={t('episodicDetail.titleKeywordPlaceholder')} />
</Form.Item>
</Col>
</Row>
</Form>
<Row gutter={16}>
<Col span={8}>
<RbCard
title={<>{t('episodicDetail.curResult')}<span className="rb:text-[#5B6167] rb:font-regular!"> ({data.total || 0}{t('episodicDetail.unix')})</span></>}
headerType="borderless"
>
{loading
? <Skeleton active />
: !data.episodic_memories || data.episodic_memories.length === 0
? <Empty />
: (
<Space size={8} direction="vertical" className="rb:w-full">
{data.episodic_memories.map((vo, index) => (
<div
key={vo.id}
className={clsx("rb:cursor-pointer rb:flex rb:items-center rb:bg-[#FFFFFF] rb:border rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5", {
'rb:border-[#DFE4ED] rb:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.16)]': selected?.id !== vo.id,
'rb:border-[#155EEF]': selected?.id === vo.id,
})}
onClick={() => setSelected(vo)}
>
<div className={clsx("rb:bg-[#369F21] rb:rounded-lg rb:text-[#FFFFFF] rb:size-6 rb:text-[12px] rb:leading-6 rb:text-center rb:mr-3", BG_COLORS[getTypeKey(vo.type)])}>{index + 1}</div>
<div className="rb:flex-1">
<div className="rb:flex rb:items-center rb:justify-between">{vo.title} <Tag color={TAG_COLORS[getTypeKey(vo.type)]}>{t(`episodicDetail.${getTypeKey(vo.type)}`)}</Tag></div>
<div className="rb:text-[#5B6167] rb:text-[12px]">{formatDateTime(vo.created_at)}</div>
</div>
</div>
))}
</Space>
)
}
</RbCard>
</Col>
<Col span={16}>
<RbCard
title={selected?.title}
headerType="borderless"
>
{detailLoading
? <Skeleton active />
: !selected || !detail
? <Empty className="rb:mt-14" />
: (
<Space size={12} direction="vertical" className="rb:w-full">
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5">
<Row gutter={[12, 16]}>
<Col span={12}>
<div className="rb:text-[#5B6167]">{t('episodicDetail.created')}<br />{formatDateTime(detail.created_at)}</div>
</Col>
<Col span={12}>
<div className="rb:text-[#5B6167]">{t('episodicDetail.episodic_type')}<br />{detail.episodic_type}</div>
</Col>
{detail.involved_objects.length > 0 && <Col span={24}>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('episodicDetail.involved_objects')}</div>
<Space size={8}>{detail.involved_objects.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
</Col>}
</Row>
</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5">
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('episodicDetail.content_records')}</div>
{detail.content_records.map((vo, index) => <div key={index} className="rb:text-[#5B6167] rb:leading-5">- {vo}</div>)}
</div>
<RbAlert>
{t('episodicDetail.emotion')}: {t(`statementDetail.${detail.emotion}`)}
</RbAlert>
</Space>
)
}
</RbCard>
</Col>
</Row>
</div>
)
}
export default EpisodicDetail

View File

@@ -20,7 +20,7 @@ const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warnin
chunk: 'warning',
}
const ForgetOverview: FC = () => {
const ForgetDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
@@ -156,4 +156,4 @@ const ForgetOverview: FC = () => {
</div>
)
}
export default ForgetOverview
export default ForgetDetail

View File

@@ -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 (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
GraphDetail
</div>
)
}
export default GraphDetail

View File

@@ -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 (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('implicitDetail.title')}</div>
<Preferences />
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('implicitDetail.portraitTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('implicitDetail.portraitSubTitle')}</div>
<Row gutter={[16, 16]} className="rb:mt-4">
<Col span={12}>
<Portrait />
</Col>
<Col span={12}>
<InterestAreas />
</Col>
</Row>
<Habits />
</div>
)
}
export default ImplicitDetail

View File

@@ -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 (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-6 rb:rounded-md rb:mb-4">{t('perceptualDetail.lastInfo')}</div>
<Row gutter={[16, 16]}>
<Col span={8}>
<PerceptualLastInfo type="last_visual" />
</Col>
<Col span={8}>
<PerceptualLastInfo type="last_listen" />
</Col>
<Col span={8}>
<PerceptualLastInfo type="last_text" />
</Col>
</Row>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-6 rb:rounded-md rb:mb-4">{t('perceptualDetail.timeLine')}</div>
<Timeline />
</div>
)
}
export default PerceptualDetail

View File

@@ -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<boolean>(false)
const [data, setData] = useState<ShortData>({} 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 (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:flex rb:justify-between rb:items-center rb:text-[#FFFFFF] rb:leading-5 rb:h-30 rb:p-5 rb:bg-[url('@/assets/images/userMemory/shortTerm.png')] rb:bg-cover">
<div className="rb:max-w-135">{t('shortTermDetail.title')}</div>
<div className="rb:grid rb:grid-cols-3 rb:gap-4">
{(['retrieval_number', 'entity', 'long_term_number'] as const).map(key => (
<div key={key} className="rb:bg-[rgba(255,255,255,0.2)] rb:rounded-lg rb:p-3.5 rb:text-[12px] rb:text-center">
<div className="rb:text-[24px] rb:leading-8 rb:mb-1">{(data as any)[key] ?? 0}</div>
{t(`shortTermDetail.${key}`)}
</div>
))}
</div>
</div>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('shortTermDetail.shortTermTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('shortTermDetail.shortTermSubTitle')}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{loading
? <Skeleton active />
: !data.short_term || data.short_term.length === 0
? <Empty />
:data.short_term?.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-6 rb:py-3">
<div className="rb:font-medium rb:text-[16px] rb:leading-5.5 rb:mb-3">{vo.message}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{vo.retrieval.map((item, index) => (
<div key={index} className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-3 rb:py-2.5 rb:leading-5">
<div className="rb:font-medium rb:mb-3">{t('shortTermDetail.query')}: {item.query}</div>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('shortTermDetail.answer')}:</div>
{item.retrieval.length > 0 ? item.retrieval.map((retrieval, retrievalIdx) => (
<div key={retrievalIdx} className="rb:text-[#5B6167] rb:text-[12px]">- {retrieval}</div>
)) : <div className="rb:text-[#5B6167] rb:text-[12px]">{t('shortTermDetail.noAnswer')}</div>}
</div>
))}
<div>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('shortTermDetail.answer')}</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-3 rb:py-2.5 rb:leading-5">{vo.answer}</div>
</div>
</Space>
</div>
))
}
</Space>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('shortTermDetail.longTermTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('shortTermDetail.shortTermSubTitle')}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{loading
? <Skeleton active />
: !data.long_term || data.long_term.length === 0
? <Empty />
: data.long_term?.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-6 rb:py-3">
<div className="rb:mb-1 rb:font-medium rb:leading-5.5">{vo.query}</div>
<div className="rb:mt-1 rb:leading-5 rb:text-[#5B6167] rb:text-[12px]">{vo.retrieval}</div>
</div>
))
}
</Space>
</div>
)
}
export default ShortTermDetail

View File

@@ -1,15 +1,23 @@
import { type FC, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
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<string>('')
useEffect(() => {
if (!id) return
@@ -23,17 +31,37 @@ const Detail: FC = () => {
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 })
}
console.log('Detail', name)
return (
<div className="rb:h-full rb:w-full">
<PageHeader
name={name}
source="node"
operation={
<Dropdown menu={{ items, onClick, selectedKeys: type ? [type] : [] }}>
<div className="rb:cursor-pointer rb:group rb:flex rb:items-center rb:gap-1">
- {type ? t(`userMemory.${type}`) : ''}
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/up_border.svg')] rb:transform-[rotate(180deg)] rb:group-hover:transform-[rotate(0deg)]"
></div>
</div>
</Dropdown>
}
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
{type === 'EMOTIONAL_MEMORY' && <StatementDetail />}
{type === 'FORGETTING_MANAGEMENT' && <ForgetDetail />}
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail />}
{type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />}
{type === 'PERCEPTUAL_MEMORY' && <PerceptualDetail />} {/** TODO */}
{type === 'EPISODIC_MEMORY' && <EpisodicDetail />}
</div>
</div>
)

View File

@@ -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 {