feat(web): memory ui upgrade

This commit is contained in:
zhaoying
2026-03-16 15:10:55 +08:00
parent f09de3a11c
commit 88598fb9fb
26 changed files with 1732 additions and 1186 deletions

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:56:54
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:57:17
* @Last Modified time: 2026-03-12 16:58:14
*/
/**
* Emotion Engine Configuration Page
@@ -11,18 +11,21 @@
*/
import React, { useState, useEffect } from 'react';
import { Row, Col, Form, Slider, Button, Alert, message, Space } from 'antd';
import { Row, Col, Form, Button, message, Space, Flex, Tooltip } from 'antd';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import RbCard from '@/components/RbCard/Card';
import strategyImpactSimulator from '@/assets/images/memory/strategyImpactSimulator.svg'
import { getMemoryEmotionConfig, updateMemoryEmotionConfig } from '@/api/memory'
import type { ConfigForm } from './types'
import CustomSelect from '@/components/CustomSelect';
import { getModelListUrl } from '@/api/models'
import Tag from '@/components/Tag'
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
import LabelWrapper from '@/components/FormItem/LabelWrapper'
import DescWrapper from '@/components/FormItem/DescWrapper'
import RbSlider from '@/components/RbSlider';
import RbAlert from '@/components/RbAlert';
/**
* Configuration field definitions
@@ -40,10 +43,11 @@ const configList = [
},
{
key: 'emotion_min_intensity',
type: 'slider',
type: 'decimal',
min: 0,
max: 1,
step: 0.05
step: 0.05,
range: [0, 1],
},
{
key: 'emotion_extract_keywords',
@@ -119,12 +123,15 @@ const EmotionEngine: React.FC = () => {
<Row gutter={[16, 16]}>
<Col span={12}>
<RbCard
title={
<div className="rb:flex rb:items-center">
<img src={strategyImpactSimulator} className="rb:w-5 rb:h-5 rb:mr-2" />
{t('emotionEngine.emotionEngineConfig')}
</div>
}
title={t('emotionEngine.emotionEngineConfig')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
extra={<Space>
<Button block onClick={handleReset}>{t('common.reset')}</Button>
<Button type="primary" loading={loading} block onClick={handleSave}>{t('common.save')}</Button>
</Space>}
className="rb:h-[calc(100vh-76px)]!"
bodyClassName="rb:h-[calc(100%-54px)] rb:overflow-y-auto! rb:p-3! rb:pt-0!"
>
<Form
form={form}
@@ -135,130 +142,152 @@ const EmotionEngine: React.FC = () => {
lambda_mem: 0.03,
}}
>
{configList.map(config => {
if (config.type === 'slider') {
<Flex vertical gap={12}>
{configList.map(config => {
if (config.type === 'decimal') {
return (
<div key={config.key} className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3">
<Flex align="center" gap={4} className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mb-2">
{t(`emotionEngine.${config.key}`)}
<Tooltip title={t(`emotionEngine.${config.key}_desc`)}>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/question.svg')]"></div>
</Tooltip>
</Flex>
<Form.Item
name={config.key}
extra={<DescWrapper
desc={<>
<span className="rb:text-[12px]">{t(`forgettingEngine.range`)}: {config.range?.join('-')}</span> | <span>{t(`forgettingEngine.type`)}: {config.type}</span>
</>}
/>}
className="rb:mb-0!"
>
<RbSlider
max={config.max}
min={config.min}
step={config.step}
isInput={true}
prefix={<span className="rb:text-[#5B6167]">{t('emotionEngine.currentValue')}:</span>}
inputClassName="rb:w-[155px]!"
/>
</Form.Item>
</div>
)
}
if (config.type === 'customSelect') {
return (
<div key={config.key} className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3">
<LabelWrapper title={t(`emotionEngine.${config.key}`)} className="rb:mb-3">
<DescWrapper desc={t(`emotionEngine.${config.key}_desc`)} className="rb:mt-1" />
</LabelWrapper>
<Form.Item
name={config.key}
className="rb:mb-0!"
>
<CustomSelect
url={config.url as string}
params={config.params}
valueKey='id'
labelKey='name'
hasAll={false}
disabled={!values?.emotion_enabled && config.key !== 'emotion_enabled'}
/>
</Form.Item>
</div>
)
}
return (
<div key={config.key} className=" rb:mb-6">
<div className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mb-2">
{t(`emotionEngine.${config.key}`)}
</div>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ">
{t(`emotionEngine.${config.key}_desc`)}
</div>
<Form.Item
name={config.key}
>
<Slider
disabled={!values?.emotion_enabled && config.key !== 'emotion_enabled'}
tooltip={{ open: false }} max={config.max} min={config.min} step={config.step} style={{ margin: '0' }} />
</Form.Item>
<div className="rb:flex rb:text-[12px] rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-5 rb:-mt-6.5">
<>{t('emotionEngine.currentValue')}: {values?.[config.key as keyof ConfigForm] || 0}</>
</div>
</div>
<SwitchFormItem
title={t(`emotionEngine.${config.key}`)}
name={config.key}
desc={<>
{config.hasSubTitle && <div className="rb:mt-1 rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`emotionEngine.${config.key}_subTitle`)}</div>}
<div className="rb:mt-1 rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`emotionEngine.${config.key}_desc`)}</div>
</>}
disabled={!values?.emotion_enabled && config.key !== 'emotion_enabled'}
className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3!"
/>
)
}
if (config.type === 'customSelect') {
return (
<div key={config.key}>
<div className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mb-2">
{t(`emotionEngine.${config.key}`)}
</div>
<Form.Item
name={config.key}
extra={t(`emotionEngine.${config.key}_desc`)}
>
<CustomSelect
url={config.url as string}
params={config.params}
valueKey='id'
labelKey='name'
hasAll={false}
disabled={!values?.emotion_enabled && config.key !== 'emotion_enabled'}
/>
</Form.Item>
</div>
)
}
return (
<SwitchFormItem
title={t(`emotionEngine.${config.key}`)}
name={config.key}
desc={<>
{config.hasSubTitle && <div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`emotionEngine.${config.key}_subTitle`)}</div>}
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`emotionEngine.${config.key}_desc`)}</div>
</>}
className="rb:mb-6"
disabled={!values?.emotion_enabled && config.key !== 'emotion_enabled'}
/>
)
})}
<Row gutter={16} className="rb:mt-3">
<Col span={12}>
<Button block onClick={handleReset}>{t('common.reset')}</Button>
</Col>
<Col span={12}>
<Button type="primary" loading={loading} block onClick={handleSave}>{t('common.save')}</Button>
</Col>
</Row>
})}
</Flex>
</Form>
</RbCard>
</Col>
<Col span={12}>
<RbCard
title={t('emotionEngine.emotion_min_intensity_description')}
title={t('emotionEngine.emotionEngineConfig')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
className="rb:h-[calc(100vh-76px)]!"
bodyClassName="rb:h-[calc(100%-54px)] rb:overflow-y-auto! rb:p-3! rb:pt-0!"
>
<div className="rb:font-medium">{t('emotionEngine.question')}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 rb:mt-2">{t('emotionEngine.answer')}</div>
<div className="rb:font-medium rb:mt-4 rb:mb-2">{t('emotionEngine.differentTitle')}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{['low', 'middle', 'high'].map((key, index) => (
<Alert
key={key}
type={(['warning', 'info', 'success'] as const)[index] as 'warning' | 'info' | 'success'}
message={
<div>
<div className="rb:w-full rb:font-medium rb:flex rb:justify-between">
{t(`emotionEngine.${key}_title`)}
<Tag color={(['warning', 'processing', 'success'] as const)[index] as 'warning' | 'processing' | 'success'}>{t(`emotionEngine.${key}_tag`)}</Tag>
</div>
<Space size={8} direction="vertical" className="rb:w-full rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">
<div><span className="rb:font-medium">{t('emotionEngine.advantage')}: </span>{t(`emotionEngine.${key}_advantage`)}</div>
<div><span className="rb:font-medium">{t('emotionEngine.shortcoming')}: </span>{t(`emotionEngine.${key}_shortcoming`)}</div>
<div><span className="rb:font-medium">{t('emotionEngine.scene')}: </span>{t(`emotionEngine.${key}_scene`)}</div>
</Space>
</div>
}
/>
))}
</Space>
<div className="rb:font-medium rb:mt-6 rb:mb-3">{t('emotionEngine.configSuggest')}</div>
<Space size={12} direction="vertical" className="rb:w-full">
{['first', 'customer_service', 'data_analysis', 'risk_warning'].map(key => (
<div className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">{t(`emotionEngine.${key}`)}: {t(`emotionEngine.${key}_desc`)}</div>
))}
</Space>
<div className="rb:font-medium rb:mt-6 rb:mb-3">{t('emotionEngine.actual_case')}</div>
<Space size={12} direction="vertical" className="rb:w-full rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
<div className="rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md">
<span className="rb:font-medium">{t('emotionEngine.user_input')}: </span>
{t('emotionEngine.user_input_message')}
</div>
{['neutral_emotion', 'minor_dissatisfaction', 'expect_improvement'].map((key, index) => (
<div className="rb:flex rb:items-center rb:justify-between rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md">
<div className="rb:w-[50%] rb:flex rb:items-center rb:justify-between rb:text-[12px]">
{t(`emotionEngine.${key}`)}
<span>{t('emotionEngine.confidence')}: {key === 'neutral_emotion' ? 0.85 : key === 'minor_dissatisfaction' ? 0.45 : 0.32}</span>
</div>
<Tag color={(['success', 'warning', 'processing'] as const)[index] as 'warning' | 'processing' | 'success'}>{t(`emotionEngine.${key}_tag`)}</Tag>
<Flex vertical gap={24} className="rb:text-[#212332]">
<div>
<div className="rb:font-medium rb:leading-5 rb:px-1 rb:mb-2.5">{t('emotionEngine.question')}</div>
<div className="rb:text-[#5B6167] rb:bg-[#F6F6F6] rb:px-3 rb:py-2.5 rb:font-regular rb:leading-5 rb:rounded-xl">
{t('emotionEngine.answer')}
</div>
))}
</Space>
</div>
<div>
<div className="rb:font-medium rb:leading-5 rb:px-1 rb:mb-2.5">{t('emotionEngine.differentTitle')}</div>
<Flex gap={10} vertical>
{['low', 'middle', 'high'].map((key, index) => (
<RbAlert
key={key}
color={(['orange', 'blue', 'green'] as const)[index] as 'orange' | 'blue' | 'green'}
>
<Flex gap={10} vertical className=" rb:text-[#5B6167] rb:text-[14px] rb:font-regular rb:leading-5">
<Flex align="center" gap={8}>
<span className="rb:font-medium rb:text-[#212332]">{t(`emotionEngine.${key}_title`)}</span>
<span className={clsx("rb:px-1 rb:rounded-sm rb:text-white rb:leading-4.5", ['rb:bg-[#FF5D34]', 'rb:bg-[#155EEF]', 'rb:bg-[#369F21]'][index])}>
{t(`emotionEngine.${key}_tag`)}
</span>
</Flex>
<div><span className="rb:font-medium rb:text-[#212332]">{t('emotionEngine.advantage')}: </span>{t(`emotionEngine.${key}_advantage`)}</div>
<div><span className="rb:font-medium rb:text-[#212332]">{t('emotionEngine.shortcoming')}: </span>{t(`emotionEngine.${key}_shortcoming`)}</div>
<div><span className="rb:font-medium rb:text-[#212332]">{t('emotionEngine.scene')}: </span>{t(`emotionEngine.${key}_scene`)}</div>
</Flex>
</RbAlert>
))}
</Flex>
</div>
<div>
<div className="rb:font-medium rb:leading-5 rb:px-1 rb:mb-2.5">{t('emotionEngine.configSuggest')}</div>
<Flex gap={10} vertical>
{['first', 'customer_service', 'data_analysis', 'risk_warning'].map(key => (
<div className="rb:bg-[#F6F6F6] rb:px-3 rb:py-2.5 rb:rounded-xl">{t(`emotionEngine.${key}`)}: {t(`emotionEngine.${key}_desc`)}</div>
))}
</Flex>
</div>
<div>
<div className="rb:font-medium rb:leading-5 rb:px-1 rb:mb-2.5">{t('emotionEngine.actual_case')}</div>
<div className="rb:bg-[#F6F6F6] rb:px-3 rb:py-2.5 rb:font-regular rb:leading-5 rb:rounded-xl">
<div className="rb:mb-2.5">
<span className="rb:font-medium">{t('emotionEngine.user_input')}: </span>
{t('emotionEngine.user_input_message')}
</div>
<Flex vertical gap={4}>
{['neutral_emotion', 'minor_dissatisfaction', 'expect_improvement'].map((key, index) => (
<Flex gap={28} align="center" justify="space-between" className="rb:bg-white rb:px-3! rb:py-2! rb:rounded-lg">
<Flex align="center" justify="space-between" className="rb:w-[55%]!">
<span className="rb:font-medium">{t(`emotionEngine.${key}`)}</span>
<span>{t('emotionEngine.confidence')}: {key === 'neutral_emotion' ? 0.85 : key === 'minor_dissatisfaction' ? 0.45 : 0.32}</span>
</Flex>
<span className={clsx('rb:text-right rb:wrap-break-word rb:flex-1', ['rb:text-[#369F21]', 'rb:text-[#FF5D34]', 'rb:text-[#155EEF]'][index])}>{t(`emotionEngine.${key}_tag`)}</span>
</Flex>
))}
</Flex>
</div>
</div>
</Flex>
</RbCard>
</Col>
{contextHolder}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:33:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-06 13:53:53
* @Last Modified time: 2026-03-12 15:44:32
*/
/**
* Memory Management Page
@@ -11,10 +11,9 @@
*/
import React, { useState, useEffect, useRef } from 'react';
import { List, Button, Space, App, Tooltip } from 'antd';
import { Button, Space, App, Flex, Row, Col } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx'
import MemoryForm from './components/MemoryForm';
import type { Memory, MemoryFormRef } from '@/views/MemoryManagement/types'
@@ -22,7 +21,6 @@ import RbCard from '@/components/RbCard/Card'
import { getMemoryConfigList, deleteMemoryConfig } from '@/api/memory'
import BodyWrapper from '@/components/Empty/BodyWrapper'
import { formatDateTime } from '@/utils/format';
import RbAlert from '@/components/RbAlert'
const MemoryManagement: React.FC = () => {
const { t } = useTranslation();
@@ -102,68 +100,69 @@ const MemoryManagement: React.FC = () => {
</div>
<BodyWrapper loading={loading} empty={data.length === 0}>
<List
grid={{ gutter: 16, column: 2 }}
loading={loading}
dataSource={data}
renderItem={(item) => (
<List.Item key={item.config_id}>
<RbCard
<Row gutter={[12, 12]}>
{data.map((item) => (
<Col key={item.config_id} span={12}>
<RbCard
title={item.config_name}
className="rb:relative"
className="rb:relative rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]!"
headerType="borderless"
headerClassName="rb:h-[46px]"
bodyClassName="rb:p-3! rb:pt-0!"
>
{item.is_system_default &&
<div className="rb:absolute rb:-right-px rb:-top-px rb:bg-[#FF5D34] rb:rounded-[0px_7px_0px_8px] rb:text-[12px] rb:text-white rb:font-regular rb:leading-4 rb:py-0.5 rb:px-1">
<div className="rb:absolute rb:right-0 rb:top-0 rb:bg-[#FF5D34] rb:rounded-[0px_12px_0px_12px] rb:text-[12px] rb:text-white rb:font-medium rb:leading-4 rb:py-0.75 rb:px-2">
{t('common.default')}
</div>
}
<Tooltip title={item.config_desc}>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.25 rb:font-regular rb:-mt-1 rb:wrap-break-word rb:line-clamp-1 rb:h-4.25">{item.config_desc}</div>
</Tooltip>
<RbAlert className="rb:mt-3 ">
<div className={clsx("rb:flex rb:gap-5 rb:font-regular rb:text-[14px]")}>
<span className="rb:text-[#5B6167]">{t('memory.scene_id')}: </span>
<span className="rb:font-medium">
{item.scene_name || '-'}
</span>
<Flex vertical gap={12}>
<div className="rb:bg-[rgba(21,94,239,0.06)] rb:rounded-lg rb:text-[#155EEF] rb:font-medium rb:leading-5 rb:py-1.5 rb:px-2">
{t('memory.scene_id')}: {item.scene_name || '-'}
</div>
</RbAlert>
<div className="rb:grid rb:grid-cols-2 rb:gap-x-4 rb:gap-y-3 rb:mt-3">
{['memoryExtractionEngine', 'forgottenEngine', 'emotionEngine', 'reflectionEngine'].map((key) => (
<div key={key} className="rb:group rb:cursor-pointer rb:bg-[#F0F3F8] rb:h-10 rb:rounded-md rb:flex rb:items-center rb:justify-between rb:p-[0_8px_0_12px] rb:text-[#5B6167] rb:font-medium"
onClick={() => handleClick(item.config_id, key)}
>
{t(`memory.${key}`)}
<span className='rb:flex rb:items-center rb:justify-end'>
{/* <StatusTag status={item[key] === 'active' ? 'success' : 'error'} text={item[key] === 'active' ? t('memory.active') : t('memory.inactive')} /> */}
<div
className="rb:w-4 rb:h-4 rb:-ml-0.75 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/memory/arrow_right.svg')] rb:group-hover:bg-[url('@/assets/images/memory/arrow_right_hover.svg')]"
<div className="rb:grid rb:grid-cols-2 rb:gap-x-3 rb:gap-y-2">
{['memoryExtractionEngine', 'forgottenEngine', 'emotionEngine', 'reflectionEngine'].map((key) => (
<Flex
key={key}
align="center"
justify="space-between"
className="rb:cursor-pointer rb:bg-[#F6F6F6] rb:h-8 rb:rounded-lg rb:font-medium rb:leading-5 rb:pl-2! rb:pr-1! rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]"
onClick={() => handleClick(item.config_id, key)}
>
{t(`memory.${key}`)}
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/memory/arrow_right.svg')] rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]"
></div>
</span>
</div>
))}
</div>
<div className={clsx("rb:mt-4 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center", {
'rb:justify-between': item.updated_at,
'rb:justify-end': !item.updated_at
})}>
{formatDateTime(item.updated_at, 'YYYY-MM-DD HH:mm:ss')}
<Space size={16}>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
onClick={() => handleEdit(item)}
></div>
{!item.is_system_default && <div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
onClick={() => handleDelete(item)}
></div>}
</Space>
</div>
</Flex>
))}
</div>
<Flex
align="center"
justify={item.updated_at ? "space-between" : "flex-end"}
className="rb:text-[12px] rb:leading-4.5 rb:font-regular rb:text-[#5B6167] rb:pl-1!"
>
{formatDateTime(item.updated_at, 'YYYY-MM-DD HH:mm:ss')}
<Space size={8}>
<div className="rb:size-4.5 rb:hover:bg-[#EBEBEB] rb:rounded-md">
<div
className="rb:size-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/edit.svg')]"
onClick={() => handleEdit(item)}
></div>
</div>
{!item.is_system_default &&
<div
className="rb:size-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/delete.svg')] rb:hover:bg-[url('@/assets/images/common/delete_hover.svg')]"
onClick={() => handleDelete(item)}
></div>
}
</Space>
</Flex>
</Flex>
</RbCard>
</List.Item>
)}
/>
</Col>
))}
</Row>
</BodyWrapper>
<MemoryForm

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 17:53:44
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-10 17:52:35
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:01:27
*/
/**
* User Memory Page
@@ -12,7 +12,7 @@
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'
import { Row, Col, Skeleton, Form, Flex } from 'antd';
import { Row, Col, Skeleton, Form, Flex, Tooltip } from 'antd';
import Empty from '@/components/Empty'
import type { Data } from './types'
@@ -20,6 +20,7 @@ import { getUserMemoryList } from '@/api/memory';
import { useUser } from '@/store/user'
import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput';
import RbStatistic from '@/components/RbStatistic';
export default function UserMemory() {
const { t } = useTranslation();
@@ -93,38 +94,41 @@ export default function UserMemory() {
{loading ?
<Skeleton active />
: filterData.length > 0 ? (
<Row gutter={[16, 16]}>
<Row gutter={[12, 12]}>
{filterData.map((item, index) => {
const { end_user, memory_num, memory_config } = item as Data;
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
return (
<Col key={index} span={8}>
<RbCard
avatar={<div className="rb:w-12 rb:h-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF] rb:mr-2">{name[0]}</div>}
title={name || '-'}
extra={<div
className="rb:w-7 rb:h-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/goto.svg')]"
></div>}
title={<Flex gap={4}>
<div className="rb:size-6 rb:text-center rb:font-semibold rb:leading-6 rb:rounded-md rb:text-white rb:bg-[#155EEF]">{name[0]}</div>
<Tooltip title={name || '-'}><div className={`rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap`}>{name || '-'}</div></Tooltip>
</Flex>}
headerType="border"
headerClassName="rb:h-[48px]! rb:mx-4!"
bodyClassName="rb:py-3! rb:px-4!"
className="rb:cursor-pointer"
onClick={() => handleViewDetail(end_user.id)}
>
<Flex align="center" justify="space-between">
<div>{t('userMemory.capacity')}</div>
<div>{memory_num?.total || 0} {t('userMemory.memoryNum')}</div>
</Flex>
<Flex align="center" justify="space-between" className="rb:mt-2.5!">
<div>{t('userMemory.type')}</div>
<div>{t(`userMemory.${item.type || 'person'}`)}</div>
</Flex>
<Row>
<Col span={12}>
<RbStatistic title={t('userMemory.capacity')} value={memory_num?.total || 0} suffix={t('userMemory.memoryNum')} />
</Col>
<Col span={12}>
<RbStatistic title={t('userMemory.type')} value={t(`userMemory.${item.type || 'person'}`)} />
</Col>
</Row>
<div className="rb:relative rb:z-2 rb:mt-3 rb:bg-[#F6F8FC] rb:rounded-lg rb-border rb:py-2 rb:px-3" onClick={handleViewMemoryConfig}>
<Flex align="center" justify="space-between" className="rb:text-[#5B6167] rb:leading-5">
<div className="rb:relative rb:z-2 rb:mt-3 rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2 rb:px-3 rb:leading-5" onClick={handleViewMemoryConfig}>
<Flex align="center" justify="space-between" className="rb:text-[#5B6167]">
{t('userMemory.memory_config_name')}
<div
className="rb:w-7 rb:h-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right.svg')]"
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right_dark.svg')]"
></div>
</Flex>
<div className="rb:font-medium rb:leading-5 rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
<div className="rb:font-medium rb:text-[#212332] rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
</div>
</RbCard>
</Col>

View File

@@ -1,16 +1,15 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:34:16
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:34:16
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 11:36:02
*/
import { type FC, useRef, useEffect } from 'react'
import { type FC } 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'
import PieChart from '@/components/Charts/PieChart'
/**
* Props for ActivationMetricsPieCard component
@@ -21,117 +20,27 @@ interface ActivationMetricsPieCardProps {
chartData: Array<Record<string, string | number>>;
loading: boolean;
}
const Colors = ['#155EEF', '#FFB048', '#FF5D34']
/**
* ActivationMetricsPieCard Component
* Displays activation value distribution as a donut chart with legend
* Shows percentage distribution of different activation levels
*/
const ActivationMetricsPieCard: FC<ActivationMetricsPieCardProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(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 (
<RbCard
title={t('forgetDetail.activationValueDistribution')}
headerType="borderless"
headerClassName="rb:min-h-[46px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-46px)]"
className="rb:h-full!"
>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <ReactEcharts
option={{
color: Colors,
tooltip: {
trigger: 'item',
textStyle: {
color: '#5B6167',
fontSize: 12,
width: 27,
height: 16,
},
formatter: '{d}%',
padding: [8, 5],
backgroundColor: '#FFFFFF',
borderColor: '#DFE4ED',
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
},
legend: {
bottom: 14 ,
padding: 0,
itemGap: 24,
itemWidth: 40,
itemHeight: 12,
borderRadius: 2,
orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['50%', '90%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 4,
width: 200,
height: 200,
left: 143,
itemStyle: {
borderRadius: 0
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 24,
fontWeight: 'bold',
color: '#212332',
formatter: '{d}%\n{b}',
}
},
labelLine: {
show: false
},
data: chartData
}
]
}}
style={{ height: '265px', width: '100%', minWidth: '400px' }}
notMerge={true}
lazyUpdate={true}
? <Loading size={249} />
: <PieChart
chartData={chartData as { name: string; value: number }[]}
height={214}
seriesWidth={150}
seriesHeight={150}
itemGap={14}
seriesLabel={false}
seriesTop={5}
/>
}
</RbCard>

View File

@@ -0,0 +1,152 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-16 15:00:07
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:00:07
*/
import { type FC, useRef, useState, useEffect } from 'react'
import { Flex, Dropdown, type MenuProps, Slider } from 'antd'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
/** Available playback speed options. */
const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
/** Format seconds into "MM:SS" display string. */
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
/**
* Props for the AudioPlayer component.
* @property src - Audio file URL to play.
* @property fileName - Display name shown beside the file icon.
* @property fileSize - Human-readable file size string (e.g. "3.2 MB").
*/
interface AudioPlayerProps {
src: string
fileName: string
fileSize: string
}
/**
* AudioPlayer A compact inline audio player with playback controls.
*
* Displays file metadata (name & size), a play/pause toggle, a seekable
* progress slider, elapsed/total time, and a dropdown menu for downloading
* the file or changing playback speed.
*
* @example
* <AudioPlayer src="/audio/demo.mp3" fileName="demo.mp3" fileSize="3.2 MB" />
*/
const AudioPlayer: FC<AudioPlayerProps> = ({ src, fileName, fileSize }) => {
const { t } = useTranslation()
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(0)
const [speed, setSpeed] = useState(1)
/* Bind native audio events to sync React state; re-binds when src changes. */
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onTime = () => setCurrent(audio.currentTime)
const onMeta = () => setDuration(audio.duration)
const onEnd = () => setPlaying(false)
audio.addEventListener('timeupdate', onTime)
audio.addEventListener('loadedmetadata', onMeta)
audio.addEventListener('ended', onEnd)
return () => {
audio.removeEventListener('timeupdate', onTime)
audio.removeEventListener('loadedmetadata', onMeta)
audio.removeEventListener('ended', onEnd)
}
}, [src])
/** Toggle between play and pause. */
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (playing) { audio.pause(); setPlaying(false) }
else { audio.play(); setPlaying(true) }
}
/** Seek to a specific position (in seconds) on the audio timeline. */
const handleSeek = (val: number) => {
if (audioRef.current) audioRef.current.currentTime = val
setCurrent(val)
}
/** Update playback speed on both React state and the native audio element. */
const setPlaybackSpeed = (s: number) => {
setSpeed(s)
if (audioRef.current) audioRef.current.playbackRate = s
}
/** Open the audio source URL in a new tab to trigger download. */
const handleDownload = () => window.open(src, '_blank')
/** Dropdown menu items: download and playback speed sub-menu. */
const mainMenu: MenuProps = {
items: [
{
key: 'download',
icon: <div className="rb:size-6 rb:bg-cover rb:cursor-pointer rb:bg-[url('@/assets/images/userMemory/download.svg')]" />,
label: t('common.download'),
onClick: handleDownload,
},
{
key: 'speed',
icon: <div className="rb:size-6 rb:bg-cover rb:cursor-pointer rb:bg-[url('@/assets/images/userMemory/play_speed.svg')]" />,
label: t('perceptualDetail.playbackSpeed'),
children: SPEEDS.map(s => ({
key: String(s),
label: <span className={s === speed ? 'rb:font-bold rb:text-[#171719]' : ''}>{s === 1 ? 'normal' : s}</span>,
onClick: () => setPlaybackSpeed(s),
})),
},
],
}
return (
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3 rb:w-full">
<audio ref={audioRef} src={src} preload="metadata" />
<Flex align="center" justify="space-between" className="rb:mb-2">
<Flex align="center" gap={12}>
<div className="rb:w-7.5 rb:h-9 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/mp3.svg')]" />
<div className="rb:flex-1">
<div className="rb:font-medium rb:leading-5 rb:text-[14px]">{fileName}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5">{fileSize || '-'}</div>
</div>
</Flex>
<Flex align="center" gap={12}>
<div
className={clsx("rb:cursor-pointer rb:size-6", {
"rb:bg-[url('@/assets/images/userMemory/play.svg')]": !playing,
"rb:bg-[url('@/assets/images/userMemory/pause.svg')]": playing,
})}
onClick={togglePlay}
></div>
<Dropdown menu={mainMenu} trigger={['click']} placement="bottomRight">
<div className="rb:cursor-pointer rb:size-6 rb:bg-[url('@/assets/images/common/more.svg')] rb:hover:bg-[url('@/assets/images/common/more_hover.svg')]"></div>
</Dropdown>
</Flex>
</Flex>
<Flex align="center" gap={8} className="rb:mt-3!">
<Slider
min={0}
max={duration || 0}
step={0.1}
value={current}
onChange={handleSeek}
tooltip={{ formatter: null }}
className="rb:flex-1 rb:m-0!"
styles={{ track: { background: '#171719' }, rail: { background: '#E4E4E4' }, handle: { display: 'none' } }}
/>
<span className="rb:text-[12px] rb:leading-4.5 rb:text-[#5B6167] rb:whitespace-nowrap">{fmt(current)} / {fmt(duration)}</span>
</Flex>
</div>
)
}
export default AudioPlayer

View File

@@ -2,13 +2,14 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 18:33:39
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:33:39
* @Last Modified time: 2026-03-16 15:01:39
*/
import { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import * as echarts from 'echarts'
import 'echarts-wordcloud'
import { Flex, Divider } from 'antd';
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -57,12 +58,12 @@ const EmotionTags: FC = () => {
*/
const getEmotionColor = (emotionType: string) => {
const colors: Record<string, string> = {
joy: '#52c41a',
anger: '#ff4d4f',
sadness: '#1890ff',
fear: '#fa8c16',
neutral: '#8c8c8c',
surprise: '#722ed1'
joy: '#FF8A4C',
anger: '#FF5D34',
sadness: '#155EEF',
fear: '#9C6FFF',
neutral: '#4DA8FF',
surprise: '#369F21'
}
return colors[emotionType] || '#8c8c8c'
}
@@ -126,21 +127,26 @@ const EmotionTags: FC = () => {
<RbCard
title={t('statementDetail.emotionTags')}
headerType="borderless"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
headerClassName="rb:min-h-[46px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-0!"
className="rb:h-full!"
>
{data?.keywords && data?.keywords.length > 0
? <div>
<div ref={chartRef} className="rb:mt-6 rb:px-6" style={{ height: '320px', width: '100%' }} />
<div className="rb:flex rb:flex-wrap rb:items-center rb:justify-center rb:gap-10 rb:text-sm rb:mt-3 rb:p-3 rb:bg-[#F0F3F8] rb:rounded-[0_0_8px_8px]">
{Object.entries(emotionStats).map(([type, count]) => {
return (
<div key={type} className="rb:flex rb:items-center rb:gap-2">
<div className="rb:w-3 rb:h-3 rb:rounded-full" style={{ backgroundColor: getEmotionColor(type) }}></div>
<span className="rb:leading-5">{t(`statementDetail.${type || 'neutral'}`)} ({count}{t('statementDetail.item')})</span>
</div>
)
})}
<div ref={chartRef} className="rb:mt-4 rb:px-3" style={{ height: '212px', width: '100%' }} />
<div className="rb:px-4 rb:pb-2.5">
<Divider className="rb:my-2.5!" />
<Flex wrap justify="space-between" className="rb:px-1.5!">
{Object.keys(emotionStats).map((type) => {
return (
<Flex key={type} align="center" gap={4}>
<div className="rb:size-1 rb:rounded-full" style={{ backgroundColor: getEmotionColor(type) }}></div>
<span className="rb:leading-5">{t(`statementDetail.${type || 'neutral'}`)}</span>
</Flex>
)
})}
</Flex>
</div>
</div>
: <Empty size={88} className="rb:h-full rb:mb-4" />

View File

@@ -1,19 +1,20 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:33:06
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:33:06
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 14:05:10
*/
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Space, Progress } from 'antd';
import { Skeleton, Space, Progress, Tooltip, Flex } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty'
import {
getImplicitHabits,
} from '@/api/memory'
import styles from '../pages/index.module.css'
/**
* Habits item data structure
@@ -72,40 +73,46 @@ const Habits = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => {
}));
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>
<RbCard
title={() => (<Space size={4}>
{t('implicitDetail.habits')}
<Tooltip title={t('implicitDetail.habitsSubTitle')}>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/userMemory/question.svg')]"></div>
</Tooltip>
</Space>)}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)] rb:overflow-y-auto!"
className="rb:h-[calc(100vh-88px)]!"
>
{loading
? <Skeleton active />
: data.length === 0
? <Empty size={88} />
: <Flex gap={12} vertical>
{data.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb-border rb:rounded-xl rb:p-3">
<Flex gap={30} align="center" justify="space-between">
<div className="rb:flex-1">
<div className="rb:mb-2.5 rb:font-medium rb:text-[#212332]">{vo.habit_description}</div>
<div className="rb:text-[#5B6167]">{vo.time_context}</div>
</div>
<Progress type="circle" strokeWidth={10} percent={vo.confidence_level} className={styles.progressCustom} />
</Flex>
{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>
</>
{vo.specific_examples.length > 0 && <div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:py-2.5 rb:px-3 rb:mt-2.5">
<div className="rb:font-medium rb:mb-1">{t('implicitDetail.specific_examples')}</div>
<ul className="rb:list-disc rb:ml-4">
{vo.specific_examples.map((item, index) => (
<li key={index} className="rb:text-[#5B6167]">{item}</li>
))}
</ul>
</div>}
</div>
))}
</Flex>
}
</RbCard>
)
})
export default Habits

View File

@@ -1,13 +1,13 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:33:01
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:33:01
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 14:58:25
*/
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Progress } from 'antd'
import { Flex } from 'antd'
import ReactEcharts from 'echarts-for-react'
import Empty from '@/components/Empty'
@@ -79,96 +79,74 @@ const Health: FC = () => {
<RbCard
title={t('statementDetail.health')}
headerType="borderless"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[28px]! rb:py-[16px]!"
headerClassName="rb:min-h-[46px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:px-[25px]! rb:pb-[30px]! rb:pt-0!"
>
{health?.health_score && health?.health_score > 0
? <>
<Row gutter={59}>
<Col span={12}>
<div className="rb:flex rb:justify-center rb:items-center">
<ReactEcharts
option={{
series: [{
type: 'pie',
radius: ['65%', '80%'],
center: ['50%', '50%'],
startAngle: 90,
data: [
{
value: health.health_score,
name: health.level,
itemStyle: {
color: '#155EEF',
borderRadius: [10, 10, 10, 10]
}
},
{
value: 100 - health.health_score,
name: '',
itemStyle: {
color: '#DFE4ED',
borderRadius: [10, 10, 10, 10]
}
}
],
label: {
show: true,
position: 'center',
formatter: '{score|' + health.health_score + '}\n{level|' + health.level + '}',
rich: {
score: {
fontSize: 36,
fontWeight: 'bold',
color: '#212332',
lineHeight: 36
},
level: {
fontSize: 14,
color: '#5B6167',
lineHeight: 20
}
}
},
labelLine: { show: false },
emphasis: { disabled: true },
itemStyle: {
borderRadius: 10
}
}]
}}
style={{ height: '200px', width: '200px' }}
/>
</div>
</Col>
<Col span={12}>
{health.dimensions && <div className="rb:space-y-7">
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.positivity_rate')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.positivity_rate.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.positivity_rate.score} showInfo={false} />
</div>
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.stability')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.stability.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.stability.score} showInfo={false} />
</div>
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.resilience')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.resilience.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.resilience.score} showInfo={false} />
</div>
</div>}
</Col>
</Row>
? <Flex vertical align="center" justify="center">
<ReactEcharts
option={{
series: [{
type: 'pie',
radius: ['75%', '90%'],
center: ['50%', '50%'],
startAngle: 90,
data: [
{
value: health.health_score,
name: health.level,
itemStyle: {
color: '#155EEF',
borderRadius: [10, 10, 10, 10]
}
},
{
value: 100 - health.health_score,
name: '',
itemStyle: {
color: '#DFE4ED',
borderRadius: [10, 10, 10, 10]
}
}
],
label: {
show: true,
position: 'center',
formatter: '{score|' + health.health_score + '}\n{level|' + health.level + '}',
rich: {
score: {
fontSize: 36,
fontWeight: 'bold',
color: '#212332',
lineHeight: 36
},
level: {
fontSize: 14,
color: '#5B6167',
lineHeight: 20
}
}
},
labelLine: { show: false },
emphasis: { disabled: true },
itemStyle: {
borderRadius: 10
}
}]
}}
style={{ height: '180px', width: '180px' }}
/>
</>
{health.dimensions && <Flex justify="space-between" className="rb:w-full rb:mt-7!">
{['positivity_rate', 'stability', 'resilience'].map(key => (
<div key={key} className="rb:text-[12px] rb:leading-4.5 rb:text-[#5B6167]">
<div className="rb:font-[MiSans-Bold] rb:font-bold rb:text-[#212332] rb:text-[14px] rb:leading-4.75 rb:mb-1">{health.dimensions[key as keyof typeof health.dimensions].score}%</div>
{t(`statementDetail.${key}`)}
</div>
))}
</Flex>}
</Flex>
: <Empty size={88} className="rb:h-full" />
}
</RbCard>

View File

@@ -1,19 +1,22 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:53
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:53
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 14:27:12
*/
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'
import { useEffect, useState, forwardRef, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Progress } from 'antd';
import { Skeleton } from 'antd';
import ReactEcharts from 'echarts-for-react';
import RbCard from '@/components/RbCard/Card'
import {
getImplicitInterestAreas,
} from '@/api/memory'
/** Default color palette for area line series */
const Colors = ['#9C6FFF', '#FFB048', '#4DA8FF', '#369F21']
const keys = ['art', 'music', 'tech', 'lifestyle'] as const
/**
* Interest category item structure
* @property {string} category_name - Category name
@@ -58,6 +61,7 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<InterestAreasItem>({} as InterestAreasItem)
const chartRef = useRef<ReactEcharts>(null)
useEffect(() => {
if (!id) return
@@ -81,27 +85,46 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =
handleRefresh: getData
}));
return (
<RbCard
title={t('implicitDetail.interestAreas')}
headerType="borderless"
>
<div className="rb-border rb:p-4 rb:rounded-xl rb:mt-4">
<div className="rb:text-[#212332] rb:font-medium rb:leading-5 rb:mb-4">{t('implicitDetail.interestAreas')}</div>
{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>
: <ReactEcharts
ref={chartRef}
option={{
color: Colors,
grid: { top: 8, left: 38, right: 8, bottom: 24 },
xAxis: {
type: 'category',
data: keys.map(k => t(`implicitDetail.${k}`)),
axisLabel: { color: '#5B6167', fontSize: 12, fontFamily: 'PingFangSC, PingFang SC', interval: 0, overflow: 'break-word', width: 60 },
axisLine: { lineStyle: { color: '#EBEBEB' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { color: '#A8A9AA', fontSize: 12, fontFamily: 'PingFangSC, PingFang SC', formatter: '{value}%' },
splitLine: { lineStyle: { color: '#EBEBEB' } },
},
series: [{
type: 'bar',
barMaxWidth: 40,
borderRadius: [4, 4, 0, 0],
data: keys.map((k, i) => ({
value: data[k]?.percentage ?? 0,
itemStyle: { color: Colors[i] }
})),
label: { show: true, position: 'top', formatter: '{c}%', color: '#5B6167', fontSize: 10 },
}]
}}
style={{ height: '200px', width: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
}
</div>
)
})
export default InterestAreas

View File

@@ -2,19 +2,22 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:23
* @Last Modified time: 2026-03-16 15:01:50
*/
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 { Skeleton, Image, Flex } from 'antd';
import clsx from 'clsx'
import RbCard from '@/components/RbCard/Card'
import AudioPlayer from './AudioPlayer'
import {
getPerceptualLastVisual,
getPerceptualLastListen,
getPerceptualLastText,
} from '@/api/memory'
import Empty from '@/components/Empty';
/**
* Perceptual last info item structure
@@ -51,7 +54,7 @@ interface PerceptualLastInfoItem {
/**
* Field keys for different perceptual types
*/
const KEYS = {
const KEYS: Record<string, string[]> = {
last_visual: ['summary', 'keywords', 'topic', 'domain', 'scene'],
last_listen: ['summary', 'keywords', 'topic', 'domain', 'speaker_count'],
last_text: ['summary', 'keywords', 'topic', 'domain', 'section_count'],
@@ -62,11 +65,13 @@ const KEYS = {
* Displays the last perceptual memory (visual, audio, or text)
* Shows file preview and metadata based on perceptual type
*/
const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text' }> = ({ type }) => {
const PerceptualLastInfo: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<PerceptualLastInfoItem>({} as PerceptualLastInfoItem)
const [type, setType] = useState('last_visual')
const [fileSize, setFileSize] = useState<string>('')
useEffect(() => {
if (!id) return
@@ -75,6 +80,7 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
const getData = () => {
if (!id || !type) return
setLoading(true)
setFileSize('')
const request = type === 'last_visual'
? getPerceptualLastVisual(id)
: type === 'last_listen'
@@ -83,7 +89,18 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
request.then((res) => {
const response = res as PerceptualLastInfoItem
setData(response)
setLoading(false)
setLoading(false)
if (response.file_path) {
fetch(response.file_path, { method: 'HEAD' })
.then(r => {
const bytes = Number(r.headers.get('content-length'))
if (!bytes) return
setFileSize(bytes < 1024 * 1024
? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1024 / 1024).toFixed(1)} MB`)
})
.catch(() => {})
}
})
.finally(() => {
setLoading(false)
@@ -99,56 +116,73 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
<RbCard
title={t(`perceptualDetail.${type}`)}
headerType="borderless"
headerClassName="rb:min-h-[50px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-4! rb:pt-0! rb:h-[calc(100%-50px)] rb:overflow-y-auto"
className="rb:h-[calc(100vh-88px)]! rb:w-full!"
>
<Flex align="center" gap={8} className="rb:mb-4!">
{Object.keys(KEYS).map(key => (
<div
key={key}
className={clsx("rb:text-[12px] rb:rounded-[14px] rb:py-1 rb:pl-2 rb:pr-3 rb:cursor-pointer", {
'rb:bg-[#171719] rb:text-white': type === key,
'rb:bg-[#F6F6F6]': type !== key
})}
onClick={() => setType(key)}
>{key}</div>))}
</Flex>
{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-[#5B6167]">{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-[#5B6167] rb:cursor-pointer" onClick={handleDownload}>{data.file_name}</div>
)
) : (
<div className="rb:text-[#5B6167]">{t('empty.tableEmpty')}</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('、') : '-'}
: <Flex vertical gap={16} className="rb:w-108">
{data.file_path
? <>
{/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(data.file_name)
? <Image src={data.file_path} alt={data.file_name} width={432} className="rb:rounded-xl rb:h-45!" />
: /\.(mp4|webm|ogg|mov)$/i.test(data.file_name)
? <Flex align="center" justify="space-between" className="rb:bg-[#F6F6F6] rb:min-h-15.5! rb:rounded-xl rb:p-3!">
</Flex>
: /\.(mp3|wav|ogg|m4a|aac)$/i.test(data.file_name)
? <AudioPlayer src={data.file_path} fileName={data.file_name} fileSize={fileSize} />
: <Flex gap={11} align="center" justify="space-between" className="rb:bg-[#F6F6F6] rb:min-h-15.5! rb:rounded-xl rb:p-3!">
<Flex gap={12} align="center">
<div className="rb:w-7.5 rb:h-9 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/file.svg')]"></div>
<div>
<div className="rb:leading-5 rb:font-medium rb:mb-1">{data.file_name}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5">
{fileSize || '-'}
</div>
</Tooltip>
)
: <div className="rb:flex-1 rb:text-right">
{typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'}
</div>
</Flex>
<div
className="rb:size-6 rb:bg-cover rb:cursor-pointer rb:bg-[url('@/assets/images/userMemory/download.svg')] rb:hover:bg-[url('@/assets/images/userMemory/download_hover.svg')]"
onClick={handleDownload}
></div>
</Flex>
}
</>
: <div className="rb:bg-[#F6F6F6] rb:min-h-15.5! rb:rounded-xl rb:p-3!">
<Empty size={44} />
</div>
}
{KEYS[type].map(key => {
const value = (data as any)[key]
return (
<div key={key} className="rb:leading-5">
<div className="rb:text-[#5B6167] rb:mb-1">{t(`perceptualDetail.${key}`)}</div>
{typeof value === 'string'
? <div>{value}</div>
: Array.isArray(value)
? <Flex wrap gap={11}>
{value.map((vo, index) => <div key={index} className="rb:bg-[#F6F6F6] rb:rounded-[13px] rb:py-1 rb:px-2 rb:text-[12px] rb:font-medium rb:leading-4.5">{vo}</div>)}
</Flex>
: '-'
}
</div>
)
})}
</Space>
</div>
</div>
)
})}
</Flex>
}
</RbCard>
)

View File

@@ -1,15 +1,14 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 14:19:15
*/
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Progress } from 'antd';
import { Skeleton, Progress, Flex } from 'antd';
import RbCard from '@/components/RbCard/Card'
import {
getImplicitPortrait,
} from '@/api/memory'
@@ -85,28 +84,26 @@ const Portrait = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => {
handleRefresh: getData
}));
return (
<RbCard
title={t('implicitDetail.portrait')}
headerType="borderless"
>
<div className="rb-border rb:p-4 rb:pb-2.25 rb:rounded-xl">
<div className="rb:text-[#212332] rb:font-medium rb:leading-5 rb:mb-4">{t('implicitDetail.portrait')}</div>
{loading
? <Skeleton active />
: <div className="rb:mt-1">
: <Flex vertical gap={14} 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>
<Flex align="center" justify="space-between" className="rb:leading-5">
<div className="rb:text-[#5B6167]">{t(`implicitDetail.${key}`)}</div>
{item?.percentage ?? 0}%
</div>
<Progress percent={item?.percentage || 0} showInfo={false} />
</Flex>
<Progress percent={item?.percentage || 0} showInfo={false} strokeColor="#171719" />
</div>
)
})}
</div>
</Flex>
}
</RbCard>
</div>
)
})
export default Portrait

View File

@@ -1,20 +1,19 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:12
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:12
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 14:30:25
*/
import { useEffect, useState, useRef, useMemo, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Skeleton } from 'antd'
import { 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'
import detailEmpty from '@/assets/images/userMemory/detail_empty.png'
import RbAlert from '@/components/RbAlert'
/**
* Preference item structure
@@ -41,7 +40,7 @@ interface PreferenceItem {
/**
* Default color palette for categories
*/
const DEFAULT_COLORS = ['#FF5D34', '#155EEF', '#9C6FFF', '#369F21', '#4DA8FF', '#FF8C00', '#32CD32', '#FF69B4', '#20B2AA', '#DDA0DD']
const DEFAULT_COLORS = ['#FF8A4C', '#FF5D34', '#155EEF', '#9C6FFF', '#4DA8FF', '#369F21']
/**
* Generate color mapping for categories
@@ -161,9 +160,6 @@ const Preferences = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =>
}
}, [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])
@@ -173,48 +169,40 @@ const Preferences = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =>
}));
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
url={detailEmpty}
subTitle={t('implicitDetail.wordEmpty')}
className="rb:h-full rb:mx-10 rb:text-center"
size={[197.81, 150]}
/>
: <>
<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>
<RbAlert color="orange">{t('implicitDetail.preferencesTip')}</RbAlert>
<div className="rb-border rb:rounded-xl rb:h-60 rb:my-3">
{loading
? <Skeleton active className="rb:px-4" />
: data && data.length > 0
? <div ref={chartRef} className="rb:px-3 rb:h-full" />
: <Empty size={88} className="rb:h-full" />
}
</div>
<div className="rb:h-[calc(100%-296px)] rb:overflow-y-auto">
{selectedWord === null
? <Empty
subTitle={t('implicitDetail.wordEmpty')}
size={96}
className="rb:h-full"
/>
: <>
<div className="rb:px-1 rb:pt-1 rb:pb-3 rb:font-medium rb:leading-5">{detailTitle}</div>
<div className="rb:bg-[#F6F6F6] rb:rounded-lg rb:px-3 rb:py-2.5">
<div className="rb:leading-5 rb:mb-2 rb:font-medium">{t('implicitDetail.context_details')}</div>
<div className="rb:leading-5">{data[selectedWord].context_details}</div>
</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>
<div className="rb:bg-[#F6F6F6] rb:rounded-lg rb:px-3 rb:py-2.5 rb:mt-3">
<div className="rb:leading-5 rb:mb-2 rb:font-medium">{t('implicitDetail.supporting_evidence')}</div>
<ul className="rb:list-disc rb:ml-4">
{data[selectedWord].supporting_evidence.map((vo, index) => (
<li key={index} className="rb:text-[#5B6167]">{vo}</li>
))}
</ul>
</div>
</>
}
</div>
</>
)
})

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:07
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:07
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 11:49:29
*/
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,6 +26,13 @@ interface RecentTrendsLineCardProps {
const Colors = ['#155EEF', '#FF5D34']
const axisLabelConfig = {
color: '#5B6167',
fontSize: 10,
lineHeight: 14,
fontFamily: 'PingFangSC, PingFang SC',
formatter: '{value}'
}
/**
* RecentTrendsLineCard Component
* Displays forgetting trends with dual Y-axis line chart
@@ -60,6 +67,9 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
<RbCard
title={t('forgetDetail.forgettingTrend')}
headerType="borderless"
headerClassName="rb:min-h-[46px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-46px)]"
className="rb:h-full!"
>
{loading
? <Loading size={249} />
@@ -92,16 +102,20 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
legend: {
bottom: 2,
padding: 0,
itemGap: 24,
itemWidth: 40,
itemHeight: 12,
borderRadius: 2,
itemGap: 8,
itemWidth: 12,
itemHeight: 6,
icon: 'roundRect',
orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
textStyle: axisLabelConfig,
data: seriesList.map((key, index) => ({
name: key === 'merged_count' ? t('forgetDetail.merged_count') : t('forgetDetail.average_activation'),
itemStyle: {
color: Colors[index] + '14',
borderColor: Colors[index],
borderWidth: 1,
}
}))
},
grid: {
top: 16,
@@ -114,39 +128,29 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
type: 'category',
data: chartData.map(item => item.date),
boundaryGap: false,
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLabel: axisLabelConfig,
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
color: '#DFE4ED'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
color: '#DFE4ED',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
show: false,
}
},
yAxis: [
{
type: 'value',
position: 'left',
axisLabel: {
color: Colors[0],
fontFamily: 'PingFangSC, PingFang SC'
},
axisLabel: axisLabelConfig,
axisLine: {
lineStyle: {
color: Colors[0]
@@ -155,14 +159,7 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
color: '#DFE4ED',
type: 'solid'
}
},
@@ -170,11 +167,7 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
{
type: 'value',
position: 'right',
axisLabel: {
color: Colors[1],
fontFamily: 'PingFangSC, PingFang SC',
formatter: '{value}'
},
axisLabel: axisLabelConfig,
axisLine: {
lineStyle: {
color: Colors[1]
@@ -183,20 +176,13 @@ const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, series
splitLine: {
show: false,
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
max: 1,
min: 0
}
],
series: getSeries()
}}
style={{ height: '265px', width: '100%', minWidth: '100%' }}
style={{ height: '214px', width: '100%', minWidth: '100%' }}
notMerge={true}
lazyUpdate={true}
/>

View File

@@ -1,13 +1,13 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:31:50
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 16:22:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:02:00
*/
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { App } from 'antd'
import { App, Flex } from 'antd'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -82,25 +82,27 @@ const Suggestions = forwardRef<{ handleRefresh: () => void; }, { refresh: () =>
<RbCard
title={t('statementDetail.suggestions')}
headerType="borderless"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[16px]! rb:pt-[20px]! rb:pb-[24px]!"
headerClassName="rb:min-h-[46px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[740px]"
>
{suggestions?.suggestions && suggestions?.suggestions.length > 0
? <>
<RbAlert className="rb:mb-3">{suggestions.health_summary}</RbAlert>
<div className="rb:space-y-8">
{suggestions.suggestions.map((item, index) => (
<div key={index}>
<div className="rb:font-medium">{index + 1}. {item.title}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2 rb:mb-2 rb:leading-5">{item.content}</div>
? <Flex vertical gap={16} className="rb:h-full! rb:overflow-y-auto!">
<RbAlert className="rb:text-[14px] rb:py-2.5! rb:px-3! rb:leading-4">{suggestions.health_summary}</RbAlert>
{suggestions.suggestions.map((item, index) => (
<div key={index} className="rb:leading-5">
<div className="rb:font-medium rb:mb-2">{index + 1}. {item.title}</div>
<ul className="rb:list-disc rb:ml-4 rb:text-[12px] rb:text-[#5B6167] rb:leading-5">
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3">
<div className="rb:mb-2">{item.content}</div>
<ul className="rb:list-disc rb:ml-4">
{item.actionable_steps.map((vo, idx) => <li key={idx}>{vo}</li>)}
</ul>
</div>
))}
</div>
</>
</div>
))}
</Flex>
: <Empty size={88} subTitle={t(loading ? 'statementDetail.suggestionLoading' : 'empty.tableEmpty')} className="rb:h-full" />
}
</RbCard>

View File

@@ -2,12 +2,13 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 18:31:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:31:36
* @Last Modified time: 2026-03-16 15:02:11
*/
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Space, Divider } from 'antd';
import { Skeleton, Row, Col, Flex } from 'antd';
import clsx from 'clsx'
import RbCard from '@/components/RbCard/Card'
import {
@@ -15,7 +16,6 @@ import {
} from '@/api/memory'
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
import Tag from '@/components/Tag'
/**
* Timeline item structure
@@ -81,30 +81,41 @@ const Timeline: FC = () => {
}
return (
<RbCard>
<RbCard
title={t('perceptualDetail.timeLine')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:pl-5! rb:pt-0! rb:pr-3! rb:pb-4! rb:h-[calc(100%-54px)] rb:overflow-y-auto"
className="rb:h-[calc(100vh-88px)]!"
>
{loading
? <Skeleton active />
: data.length === 0
? <Empty />
: <Space size={8} direction="vertical" className="rb:w-full">
: <Flex gap={12} vertical>
{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-1 rb:pb-4">
<div className="rb:flex rb:justify-between">
<div className="rb:w-150 rb:leading-5 rb:font-medium">{vo.summary}</div>
<div className="rb:text-[#5B6167] rb:font-medium rb:flex-1 rb:text-right">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div>
</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-2">{[vo.domain, vo.topic].join(' | ')}</div>
<Row key={vo.id}className="rb:flex rb:gap-6 rb:min-h-16">
<Col flex="90px" className="rb:leading-5 rb:font-semibold">
<Flex vertical gap={12} align="center" justify="center" className="rb:h-full!">
<span className="rb:text-center">{formatDateTime(vo.created_time)}</span>
<div className={clsx("rb:flex-1 rb:w-px", {
'rb:bg-[#5B6167]!': index !== data.length - 1
})} />
</Flex>
</Col>
<Col flex="1" className="rb:mb-1! rb:bg-[#F6F6F6] rb:rounded-xl rb:py-3 rb:px-4">
<div className="rb:leading-4.5 rb:font-bold rb:text-[12px] rb:font-[MiSans-Bold]">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div>
<Space size={8} className="rb:mt-2">{vo.keywords.map(tag => <Tag>{tag}</Tag>)}</Space>
</div>
</div>
<div className="rb:leading-5 rb:mt-2">{vo.summary}</div>
<div className="rb:leading-5 rb:text-[#5B6167] rb:mt-2">{[vo.domain, vo.topic].join(' | ')}</div>
<Flex gap={8} wrap className="rb:mt-2!">
{vo.keywords.map((tag, index) => <div key={index} className="rb:bg-white rb:rounded-[13px] rb:py-1 rb:px-2 rb:font-medium rb:leading-4.5 rb:text-[12px]">{tag}</div>)}
</Flex>
</Col>
</Row>
))}
</Space>
</Flex>
}
</RbCard>
)

View File

@@ -2,13 +2,13 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 18:31:24
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:31:24
* @Last Modified time: 2026-03-16 15:02:21
*/
import { type FC, useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import ReactEcharts from 'echarts-for-react'
import { Progress, Row, Col } from 'antd'
import { Progress, Row, Col, Flex} from 'antd'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -88,6 +88,7 @@ const WordCloud: FC = () => {
}))
return {
color: ['#155EEF'],
tooltip: {
trigger: 'item',
formatter: (params: any) => {
@@ -100,7 +101,24 @@ const WordCloud: FC = () => {
indicator: radarData.map(item => ({
name: t(`statementDetail.${item.name}`),
max: 100,
min: 1
min: 1,
color: '#5B6167',
axisLine: {
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: false,
},
axisLabel: {
show: true,
color: '#A8A9AA',
fontSize: 10,
customValues: [20, 40, 60, 80, 100],
align: 'center',
margin: 0,
}
}))
},
series: [{
@@ -108,7 +126,8 @@ const WordCloud: FC = () => {
name: 'Emotion Intensity',
data: [{
value: radarData.map(item => item.value),
name: 'Emotion Intensity'
name: 'Emotion Intensity',
symbol: 'circle'
}]
}]
}
@@ -118,33 +137,38 @@ const WordCloud: FC = () => {
<RbCard
title={t('statementDetail.wordCloud')}
headerType="borderless"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[28px]! rb:py-[16px]!"
headerClassName="rb:min-h-[50px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName='rb:px-[22px]! rb:pb-[28px]! rb:pt-0! rb:h-[calc(100%-54px)]'
className="rb:h-full!"
>
{wordCloud?.total_count && wordCloud?.total_count > 0
? <Row gutter={50}>
? <Row gutter={58}>
<Col span={12}>
<ReactEcharts ref={chartRef} option={radarOption} style={{ width: '100%', height: 'calc(100% - 100px)' }} />
<div className="rb:mb-4 rb:text-center rb:bg-[#F5F7FC] rb:rounded-lg rb:p-2.5 rb:mt-4">
<span className="rb:text-[#155EEF] rb:text-[28px] rb:font-bold rb:leading-8">{wordCloud.total_count}</span><br />
<ReactEcharts
ref={chartRef}
option={radarOption}
style={{ width: '100%', height: 'calc(100% - 88px)' }}
/>
<div className="rb:text-center rb:bg-[#F6F6F6] rb:rounded-lg rb:p-2.5 rb:mt-4">
<span className="rb:font-[MiSans-Heavy] rb:font-bold rb:text-[24px] rb:leading-8">{wordCloud.total_count}</span><br />
<span className="rb:text-[#5B6167] rb:leading-5">{t('statementDetail.totalCount')}</span>
</div>
</Col>
<Col span={12}>
<div className="rb:space-y-5">
<Flex vertical gap={20} className="rb:pt-1!">
{wordCloud.tags.map(item => (
<div key={item.emotion_type}>
<div className="rb:flex rb:items-center rb:justify-between">
<div>
<span className="rb:font-medium">{t(`statementDetail.${item.emotion_type}`)}</span>
<span className="rb:font-regular rb:text-[#5B6167]"> ( {item.count} {t('statementDetail.pieces')} )</span>
<span className="rb:font-medium rb:text-[#212332]">{t(`statementDetail.${item.emotion_type}`)}</span>
<span className="rb:font-regular rb:text-[#5B6167]">({item.count} {t('statementDetail.pieces')})</span>
</div>
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{item.percentage.toFixed(1)}%</div>
<div className="rb:font-medium">{item.percentage.toFixed(1)}%</div>
</div>
<Progress strokeColor="#155EEF" percent={item.percentage} showInfo={false} />
</div>
))}
</div>
</Flex>
</Col>
</Row>
: <Empty size={88} />

View File

@@ -1,8 +1,14 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-08 19:46:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:03:50
*/
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 { Row, Col, Select, Form, Skeleton, Input, Flex } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getEpisodicOverview,
@@ -10,29 +16,40 @@ import {
} from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag'
import RbAlert from '@/components/RbAlert'
import Empty from '@/components/Empty'
/** Single episodic memory item returned by the overview API. */
interface EpisodicMemory {
id: string;
title: string;
type: string;
created_at: number;
}
/** Response shape of the episodic overview API. */
interface EpisodicOverviewData {
/** Count of memories matching the current filter. */
total: number;
/** Total count of all episodic memories (unfiltered). */
total_all: number;
episodic_memories: EpisodicMemory[]
}
/** Full detail of a single episodic memory entry. */
interface EpisodicMemoryDetail {
id: string;
created_at: number;
/** People or entities involved in this episode. */
involved_objects: string[];
/** Category such as conversation, learning, decision, etc. */
episodic_type: string;
/** Ordered list of content paragraphs describing the episode. */
content_records: string[];
/** Emotion label associated with this episode (e.g. "joy", "neutral"). */
emotion: string;
}
/** Maps episodic type keys to Ant Design Tag color presets. */
const TAG_COLORS: Record<string, "processing" | "success" | "warning" | "error" | "default"> = {
conversation: "processing",
project_work: "success",
@@ -41,16 +58,8 @@ const TAG_COLORS: Record<string, "processing" | "success" | "warning" | "error"
important_event: "error",
default: 'default'
}
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]",
default: 'rb:bg-[#F0F3F8] rb:text-[#5B6167]!'
}
// Map display types to internal keys
/** Normalise a display-friendly type string (e.g. "Project/Work") to its internal key (e.g. "project_work"). */
const getTypeKey = (type: string): string => {
if (!type) return 'default'
const typeMap: Record<string, string> = {
@@ -62,22 +71,34 @@ const getTypeKey = (type: string): string => {
}
return typeMap[type] || type.toLowerCase().replace(/[^a-z0-9]/g, '_')
}
/**
* EpisodicDetail Displays a user's episodic memories in a master-detail layout.
*
* Left panel: filterable & searchable list of episodic memory cards.
* Right panel: full detail view of the selected episode including metadata,
* content records and emotion label.
*
* Route param `id` is the end-user ID whose memories are being viewed.
*/
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)
/** Reactive form values used as filter params (time_range, episodic_type, title_keyword). */
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)
/* Fetch overview when the route user ID changes. */
useEffect(() => {
if (!id) return
getData()
}, [id])
/** Fetch the episodic memory overview list with current filter values. */
const getData = () => {
if (!id) return
setLoading(true)
@@ -99,14 +120,17 @@ const EpisodicDetail: FC = () => {
})
}
/* Re-fetch overview whenever filter form values change. */
useEffect(() => {
getData()
}, [values])
/* Load detail whenever a different memory card is selected. */
useEffect(() => {
getDetail()
}, [selected])
/** Fetch full detail for the currently selected episodic memory. */
const getDetail = () => {
if (!selected || !selected.id) return
@@ -124,133 +148,148 @@ const EpisodicDetail: FC = () => {
}
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"
bodyClassName="rb:h-[calc(100vh-349px)] rb:overflow-y-auto"
>
{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: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 rb:w-[calc(100%-36px)]">
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{vo.title}</div>
{vo.type && <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>
<Row gutter={16}>
<Col flex="400px">
<RbCard
title={<div className="rb:leading-5.5!">
<span className="rb:font-[MiSans-Bold] rb:font-bold">{t('episodicDetail.curResult')}</span>
<span className="rb:text-[#5B6167] rb:font-regular!"> ({data.total || 0}{t('episodicDetail.unix')})</span>
</div>}
headerType="borderless"
className="rb:h-[calc(100vh-88px)]!"
headerClassName="rb:min-h-[38px]! rb:pt-3! rb:mb-0!"
bodyClassName="rb:p-3! rb:pb-0! rb:h-[calc(100%-38px)]!"
>
<Form form={form} initialValues={{ time_range: 'all', episodic_type: 'all' }}>
<Row gutter={[8, 8]} className="rb:mb-3">
<Col span={12}>
<Form.Item name="time_range" noStyle>
<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') },
]}
className="rb:w-full"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="episodic_type" noStyle>
<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') },
]}
className="rb:w-full"
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="title_keyword" noStyle>
<Input placeholder={t('episodicDetail.titleKeywordPlaceholder')} />
</Form.Item>
</Col>
</Row>
</Form>
{loading
? <Skeleton active />
: !data.episodic_memories || data.episodic_memories.length === 0
? <Empty />
: (
<Flex gap={12} vertical className="rb:overflow-y-auto rb:h-[calc(100%-84px)] rb:pb-3!">
{data.episodic_memories.map((vo, index) => (
<Flex
key={vo.id}
gap={12}
align="center"
className={clsx("rb:cursor-pointer rb:rounded-xl rb:px-3! rb:py-2!", {
'rb-border': selected?.id !== vo.id,
'rb:border rb:border-[#171719]': selected?.id === vo.id,
})}
onClick={() => setSelected(vo)}
>
<div className="rb:rounded-md rb:text-[#FFFFFF] rb:size-5 rb:text-[10px] rb:leading-3.5 rb:text-center rb:py-0.75 rb:bg-[#171719]">{index + 1}</div>
<div className="rb:flex-1 rb:w-[calc(100%-36px)]">
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1 rb:text-[#212332] rb:font-medium rb:leading-5 rb:mb-1">{vo.title}</div>
<Flex align="center" justify="space-between" className="rb:text-[#5B6167] rb:text-[12px]">
{formatDateTime(vo.created_at)}
{vo.type && <Tag color={TAG_COLORS[getTypeKey(vo.type)]}>{t(`episodicDetail.${getTypeKey(vo.type)}`)}</Tag>}
</Flex>
</div>
))}
</Space>
)
}
</RbCard>
</Col>
<Col span={16}>
<RbCard
title={selected?.title}
headerType="borderless"
bodyClassName="rb:h-[calc(100vh-349px)] rb:overflow-y-auto"
>
{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>
</Flex>
))}
</Flex>
)
}
</RbCard>
</Col>
<Col flex="1">
<RbCard
title={selected?.title}
headerType="borderless"
className="rb:h-[calc(100vh-88px)]!"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)]! rb:overflow-y-auto"
>
{detailLoading
? <Skeleton active />
: !selected || !detail
? <Empty className="rb:mt-14" />
: (
<Flex gap={16} vertical>
<div className="rb-border rb:rounded-xl rb:px-4 rb:py-3">
<Row gutter={12}>
<Col span={8}>
<div className="rb:text-[#5B6167] rb:leading-5">
{t('episodicDetail.created')}
<div className="rb:font-medium rb:mt-1 rb:text-[#171719]">{formatDateTime(detail.created_at)}</div>
</div>
</Col>
<Col span={8}>
<div className="rb:text-[#5B6167] rb:leading-5">
{t('episodicDetail.episodic_type')}
<div className="rb:font-medium rb:mt-1 rb:text-[#171719]">{detail.episodic_type}</div>
</div>
</Col>
{detail.involved_objects.length > 0 && <Col span={8}>
<div className="rb:text-[#5B6167] rb:leading-5">
{t('episodicDetail.involved_objects')}
<Flex gap={8} className="rb:mt-1!">{detail.involved_objects.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Flex>
</div>
</Col>}
</Row>
</div>
<div>
<div className="rb:font-medium rb:leading-5 rb:mb-2 rb:pl-1">{t('episodicDetail.content_records')}</div>
<ul className="rb:leading-5.5 rb:list-disc rb-border rb:rounded-xl rb:pl-8 rb:pr-4 rb:py-3">
{detail.content_records.map((vo, index) => <li key={index}>{vo}</li>)}
</ul>
</div>
<div>
<div className="rb:font-medium rb:leading-5 rb:mb-2 rb:pl-1">{t('episodicDetail.emotion')}</div>
<div className="rb-border rb:rounded-xl rb:px-4 rb:py-3">
{detail.emotion
? t(`episodicDetail.${detail.emotion || 'none'}`)
: <Empty size={96} className="rb:pt-1! rb:pb-3.5!" />
}
</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(`episodicDetail.${detail.emotion || 'none'}`)}
</RbAlert>
</Space>
)
}
</RbCard>
</Col>
</Row>
</div>
</div>
</Flex>
)
}
</RbCard>
</Col>
</Row>
)
}
export default EpisodicDetail

View File

@@ -1,7 +1,16 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-10 17:35:17
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:05:06
*/
import { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Row, Col } from 'antd'
import { Skeleton, Row, Col, Flex } from 'antd'
import * as echarts from 'echarts'
import 'echarts-wordcloud'
import RbCard from '@/components/RbCard/Card'
import {
getExplicitMemory,
@@ -10,40 +19,67 @@ import { formatDateTime } from '@/utils/format'
import Empty from '@/components/Empty'
import ExplicitDetailModal from '../components/ExplicitDetailModal'
/** An episodic (event-based) memory entry with a title and free-text content. */
export interface EpisodicMemory {
id: string;
title: string;
content: string;
created_at: number;
}
/** A semantic (concept-based) memory entry extracted as a named entity. */
export interface SemanticMemory {
id: string;
/** Entity name displayed in the word cloud. */
name: string;
/** Classification of the entity (e.g. person, location, concept). */
entity_type: string;
/** Brief definition or description of the entity. */
core_definition: string;
created_at: number;
}
/** Combined API response containing both memory categories. */
interface Data {
episodic_memories: EpisodicMemory[];
semantic_memories: SemanticMemory[]
}
/** Imperative handle exposed by ExplicitDetailModal for opening the detail drawer. */
export interface ExplicitDetailModalRef {
handleOpen: (vo: EpisodicMemory | SemanticMemory) => void;
}
/** Rotating colour palette used for word-cloud text. */
const DEFAULT_COLORS = ['#FF8A4C', '#FF5D34', '#155EEF', '#9C6FFF', '#4DA8FF', '#369F21']
/**
* ExplicitDetail Two-column view of a user's explicit memories.
*
* Left column: scrollable list of episodic memory cards (title + content).
* Right column: ECharts word cloud built from semantic memory entity names;
* clicking a word opens the detail modal.
*
* Route param `id` is the end-user ID whose memories are displayed.
*/
const ExplicitDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const explicitDetailModalRef = useRef<ExplicitDetailModalRef>(null)
/** Container element for the ECharts word-cloud instance. */
const wordCloudRef = useRef<HTMLDivElement>(null)
/** Keeps a stable reference to the ECharts instance for cleanup. */
const chartInstance = useRef<echarts.ECharts | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<Data>({ episodic_memories: [], semantic_memories: [] })
/* Fetch data whenever the route user ID changes. */
useEffect(() => {
if (!id) return
getData()
}, [id])
/** Load both episodic and semantic memories for the current user. */
const getData = () => {
if (!id) return
setLoading(true)
@@ -56,54 +92,98 @@ const ExplicitDetail: FC = () => {
setLoading(false)
})
}
/** Open the detail modal for a given memory item. */
const handleView = (item: EpisodicMemory | SemanticMemory) => {
explicitDetailModalRef.current?.handleOpen(item)
}
return (
<div className="rb:h-full rb:w-full">
<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-3 rb:rounded-md rb:mb-4">{t('explicitDetail.episodic_memories')}</div>
{loading ?
<Skeleton active />
: data.episodic_memories?.length > 0 ? (
<Row gutter={[16, 16]}>
{data.episodic_memories.map(item => (
<Col key={item.id} span={6}>
<RbCard
title={item.title}
className="rb:h-full! rb:cursor-pointer"
onClick={() => handleView(item)}
>
<div>{formatDateTime(item.created_at)}</div>
<div>{item.content}</div>
</RbCard>
</Col>
))}
</Row>
) : <Empty />}
<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('explicitDetail.semantic_memories')}</div>
{loading ?
<Skeleton active />
: data.semantic_memories?.length > 0 ? (
<Row gutter={[16, 16]}>
{data.semantic_memories.map(item => (
<Col key={item.id} span={6}>
<RbCard
title={item.name}
className="rb:h-full! rb:cursor-pointer"
onClick={() => handleView(item)}
>
<div>{item.core_definition}</div>
</RbCard>
</Col>
))}
</Row>
) : <Empty />}
/**
* Initialise / re-render the word cloud whenever semantic memories change.
* Each word is clickable and opens the detail modal for that entity.
* The chart instance is disposed on cleanup to prevent memory leaks.
*/
useEffect(() => {
if (!wordCloudRef.current || !data.semantic_memories?.length) return
if (chartInstance.current) chartInstance.current.dispose()
chartInstance.current = echarts.init(wordCloudRef.current)
chartInstance.current.setOption({
series: [{
type: 'wordCloud',
gridSize: 8,
sizeRange: [14, 56],
rotationRange: [-45, 45],
shape: 'pentagon',
width: '100%',
height: '100%',
textStyle: { fontFamily: 'sans-serif', fontWeight: 'bold' },
emphasis: { textStyle: { shadowBlur: 10, shadowColor: '#333' } },
data: data.semantic_memories.map((item, index) => ({
name: item.name,
value: 50 + (index % 5) * 10,
itemIndex: index,
textStyle: { color: DEFAULT_COLORS[index % DEFAULT_COLORS.length] }
}))
}]
})
chartInstance.current.on('click', (params) => {
const item = data.semantic_memories[(params.data as any).itemIndex]
if (item) handleView(item)
})
return () => { chartInstance.current?.dispose(); chartInstance.current = null }
}, [data.semantic_memories])
return (
<Row gutter={12} className="rb:h-full">
<Col span={12}>
<RbCard
title={t('explicitDetail.episodic_memories')}
headerType="borderless"
headerClassName="rb:min-h-[50px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-50px)] rb:overflow-y-auto!"
className="rb:h-[calc(100vh-88px)]!"
>
{loading ?
<Skeleton active />
: data.episodic_memories?.length > 0 ? (
<Flex gap={12} vertical>
{data.episodic_memories.map(item => (
<div
key={item.id}
className="rb:cursor-pointer rb:bg-[#F6F6F6] rb:rounded-xl rb:pt-2.5 rb:px-3 rb:pb-3"
onClick={() => handleView(item)}
>
<Flex align="center" justify="space-between">
<span className="rb:font-medium rb:pl-1">{item.title}</span>
<div className="rb:textt-[#5B6167] rb:leading-4.25 rb:text-[12px]">{formatDateTime(item.created_at)}</div>
</Flex>
<div className="rb:bg-white rb:rounded-lg rb:py-2.5 rb:px-3 rb:mt-2.5 rb:leading-5">{item.content}</div>
</div>
))}
</Flex>
) : <Empty />
}
</RbCard>
</Col>
<Col span={12}>
<RbCard
title={t('explicitDetail.semantic_memories')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)] rb:overflow-y-auto!"
className="rb:h-[calc(100vh-88px)]!"
>
{loading ?
<Skeleton active />
: data.semantic_memories?.length > 0
? <div ref={wordCloudRef} className="rb:h-full rb:w-full rb:cursor-pointer" />
: <Empty />
}
</RbCard>
</Col>
<ExplicitDetailModal
ref={explicitDetailModalRef}
/>
</div>
</Row>
)
}
export default ExplicitDetail

View File

@@ -1,7 +1,14 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-07 20:37:34
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:07:50
*/
import { useEffect, useState, useMemo, forwardRef, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Progress, App } from 'antd'
import { Row, Col, Progress, App, Table } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getForgetStats,
@@ -9,11 +16,11 @@ import {
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'
import ForgetRefreshModal from '../components/ForgetRefreshModal'
import ForgetRefreshModal from '../components/ForgetRefreshModal';
/** Maps node type keys to StatusTag colour presets for the pending-nodes table. */
const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warning' | 'error' | 'lightBlue'> = {
statement: 'success',
entity: 'purple',
@@ -21,10 +28,25 @@ const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warnin
chunk: 'warning',
}
/** Imperative handle exposed by ForgetRefreshModal for triggering the refresh dialog. */
export interface ForgetRefreshModalRef {
handleOpen: () => void;
}
/**
* ForgetDetail Dashboard for the forgetting engine of a single user.
*
* Layout (top → bottom):
* 1. Overview row (3 metric cards): total memory nodes, memory health
* (average activation vs threshold), and forgetting-risk count.
* 2. Pie chart (activation distribution) + line chart (7-day trends).
* 3. Pending-nodes table listing low-activation memories at risk of being forgotten.
*
* The parent can trigger a manual forgetting refresh via the imperative
* `handleRefresh` method exposed through `forwardRef`.
*
* Route param `id` is the end-user ID.
*/
const ForgetDetail = forwardRef((_props, ref) => {
const { t } = useTranslation()
const { id } = useParams()
@@ -33,11 +55,16 @@ const ForgetDetail = forwardRef((_props, ref) => {
const [data, setData] = useState<ForgetData>({} as ForgetData)
const forgetRefreshModalRef = useRef<ForgetRefreshModalRef>(null)
/* Fetch stats whenever the route user ID changes. */
useEffect(() => {
if (!id) return
getData()
}, [id])
/**
* Load forgetting-engine statistics for the current user.
* @param flag - When true, shows a success toast after loading (used after manual refresh).
*/
const getData = (flag: boolean = false) => {
if (!id) return
setLoading(true)
@@ -53,6 +80,10 @@ const ForgetDetail = forwardRef((_props, ref) => {
setLoading(false)
})
}
/**
* Derive pie-chart data from activation metrics.
* Splits total nodes into three zones: healthy, observation (no activation), and forgetting (low activation).
*/
const chartData = useMemo(() => {
const { activation_metrics } = data
if (!activation_metrics) return []
@@ -67,6 +98,10 @@ const ForgetDetail = forwardRef((_props, ref) => {
}, [data.activation_metrics, t])
/**
* Prepare line-chart series from the recent 7-day trend data.
* Returns merged_count (daily merged nodes) and average_activation as two series.
*/
const seriesList = useMemo(() => {
const { recent_trends = [] } = data
if (!recent_trends || recent_trends.length === 0) return { chartData: [], seriesList: [] }
@@ -77,105 +112,138 @@ const ForgetDetail = forwardRef((_props, ref) => {
}
}, [data.recent_trends])
/** Open the forgetting-refresh confirmation modal. */
const handleRefresh = () => {
forgetRefreshModalRef.current?.handleOpen()
}
/* Expose handleRefresh to parent components via ref. */
useImperativeHandle(ref, () => ({
handleRefresh
}));
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('forgetDetail.title')}</div>
<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={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.totalMemory')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data?.activation_metrics?.total_nodes ?? 0}</div>
<div className="rb:mt-4 rb:grid rb:grid-cols-2 rb:gap-x-2 rb:gap-y-5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2">
{['statement_count', 'entity_count', 'summary_count', 'chunk_count'].map((key, index) => (
<div key={index}>
<div className="rb:text-[16px] rb:font-bold rb:leading-5.5">{data?.node_distribution?.[key as keyof typeof data.node_distribution] ?? 0}</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mt-1">{t(`forgetDetail.${key}`)}</div>
</div>
))}
</div>
</RbCard>
</Col>
<Col span={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.MemoryHealth')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data?.activation_metrics?.average_activation_value ?? 0}</div>
<Progress className="rb:mt-px" showInfo={false} percent={data?.activation_metrics?.average_activation_value ?? 0} />
<div className="rb:mt-4 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">{t('forgetDetail.healthStatus')}</div>
<div className="rb:text-[20px] rb:font-semibold rb:leading-7">{data?.activation_metrics?.average_activation_value > data.activation_metrics?.forgetting_threshold ? t('forgetDetail.healthy') : t('forgetDetail.unhealthy')}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.25 rb:mt-2">
{t('forgetDetail.average')}<br />
{t('forgetDetail.threshold')}{data.activation_metrics?.forgetting_threshold ?? 0}
<Row gutter={[12, 12]}>
<Col span={12}>
<RbCard
title={t('forgetDetail.overviewTitle')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:overflow-visible!"
>
<div className="rb:grid rb:grid-cols-3 rb:gap-3">
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-2 rb:pt-3">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mb-1">{t('forgetDetail.totalMemory')}</div>
<div className="rb:text-[18px] rb:font-[MiSans-Bold] rb:font-bold rb:leading-6 rb:mb-2">{data?.activation_metrics?.total_nodes ?? 0}</div>
<div className="rb:bg-white rb:rounded-lg rb:p-3 rb:grid rb:grid-cols-2 rb:gap-x-2 rb:gap-y-6">
{['statement_count', 'entity_count', 'summary_count', 'chunk_count'].map((key, index) => (
<div key={index}>
<div className="rb:font-[MiSans-Bold] rb:font-bold rb:leading-4.75">{data?.node_distribution?.[key as keyof typeof data.node_distribution] ?? 0}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mt-1">{t(`forgetDetail.${key}`)}</div>
</div>
))}
</div>
</div>
</RbCard>
</Col>
<Col span={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.riskOfForgetting')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data.activation_metrics?.low_activation_nodes ?? 0}</div>
<div className="rb:mb-31.5 rb:text-[#A8A9AA] rb:text-[12px] rb:leading-4 rb:font-regular rb:mt-1">{t('forgetDetail.low_nodes')}</div>
</RbCard>
</Col>
</Row>
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-2 rb:pt-3">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mb-1">{t('forgetDetail.MemoryHealth')}</div>
<div className="rb:text-[18px] rb:font-[MiSans-Bold] rb:font-bold rb:leading-6">{data?.activation_metrics?.average_activation_value ?? 0}</div>
<div className="rb:-mt-1 rb:mb-2">
<Progress showInfo={false} percent={data?.activation_metrics?.average_activation_value ?? 0} />
</div>
<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.memoryHealthVisualization')}</div>
<Row gutter={16}>
<Col span={12}>
<ActivationMetricsPieCard chartData={chartData} loading={loading} />
</Col>
<Col span={12}>
<RecentTrendsLineCard chartData={seriesList.chartData} seriesList={seriesList.seriesList} loading={loading} />
</Col>
</Row>
<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.pending_nodes')}</div>
<Table
rowKey='node_id'
initialData={data.pending_nodes ?? []}
columns={[
{
title: t('forgetDetail.content_summary'),
dataIndex: 'content_summary',
key: 'content_summary',
width: 340,
render: (content_summary) => <div className="rb:wrap-break-word rb:line-clamp-2">{content_summary}</div>
},
{
title: t('forgetDetail.node_type'),
dataIndex: 'node_type',
key: 'node_type',
render: (node_type: string) => {
return <StatusTag status={statusTagColors[node_type] || 'default'} text={node_type} />}
},
{
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}
/>
<div className="rb:bg-white rb:rounded-lg rb:p-3 rb:pt-2">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mb-1">{t('forgetDetail.healthStatus')}</div>
<div className="rb:text-[16px] rb:font-[MiSans-Bold] rb:font-bold rb:leading-6 rb:mb-3">
{data?.activation_metrics?.average_activation_value > data.activation_metrics?.forgetting_threshold
? t('forgetDetail.healthy')
: t('forgetDetail.unhealthy')
}
</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5">
{t('forgetDetail.average')}<br />
{t('forgetDetail.threshold')}{data.activation_metrics?.forgetting_threshold ?? 0}
</div>
</div>
</div>
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:p-2 rb:pt-3">
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.5 rb:mb-1">{t('forgetDetail.riskOfForgetting')}</div>
<div className="rb:text-[18px] rb:font-[MiSans-Bold] rb:font-bold rb:leading-6">{data.activation_metrics?.low_activation_nodes ?? 0}</div>
<div className="rb:text-[#5B6167] rb:text-[10px] rb:leading-3.5">{t('forgetDetail.low_nodes')}</div>
<div className="rb:bg-white rb:rounded-lg rb:mt-2">
<div className="rb:h-29.5 rb:w-full rb:bg-cover rb:bg-[url('@/assets/images/userMemory/forget.png')]"></div>
</div>
</div>
</div>
</RbCard>
</Col>
<Col span={6}>
<ActivationMetricsPieCard chartData={chartData} loading={loading} />
</Col>
<Col span={6}>
<RecentTrendsLineCard chartData={seriesList.chartData} seriesList={seriesList.seriesList} loading={loading} />
</Col>
<Col span={24}>
<RbCard
title={t('forgetDetail.pending_nodes')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:py-0! rb:h-[calc(100%-54px)]"
className="rb:h-full!"
>
<Table
rowKey='node_id'
dataSource={data.pending_nodes ?? []}
columns={[
{
title: t('forgetDetail.content_summary'),
dataIndex: 'content_summary',
key: 'content_summary',
render: (content_summary) => <div className="rb:wrap-break-word rb:line-clamp-2">{content_summary}</div>
},
{
title: t('forgetDetail.node_type'),
dataIndex: 'node_type',
key: 'node_type',
width: '20%',
render: (node_type: string) => {
return <StatusTag status={statusTagColors[node_type] || 'default'} text={node_type} />
}
},
{
title: t('forgetDetail.last_access_time'),
dataIndex: 'last_access_time',
key: 'last_access_time',
width: '20%',
render: (last_access_time) => <span className="rb:text-[#5B6167]">{formatDateTime(last_access_time, 'YYYY-MM-DD HH:mm')}</span>
},
{
title: t('forgetDetail.activation_value'),
dataIndex: 'activation_value',
key: 'activation_value',
width: '20%',
render: (activation_value) => <span className="rb:text-[#5B6167]">{activation_value}</span>
},
]}
pagination={{
pageSize: 5,
showQuickJumper: true,
className: 'rb:mt-5! rb:mb-5.75!'
}}
className="table-header-has-bg"
/>
</RbCard>
</Col>
<ForgetRefreshModal
ref={forgetRefreshModalRef}
refresh={getData}
/>
</div>
{/* <div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('forgetDetail.title')}</div>
</div> */}
</Row>
)
})
export default ForgetDetail

View File

@@ -2,9 +2,9 @@
* @Author: ZhaoYing
* @Date: 2026-01-08 19:46:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 16:26:55
* @Last Modified time: 2026-03-16 14:27:58
*/
import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react'
import { forwardRef, useImperativeHandle, useRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Row, Col, App } from 'antd'
import { useParams } from 'react-router-dom'
@@ -17,6 +17,8 @@ import {
generateProfile,
implicitCheckData,
} from '@/api/memory'
import RbCard from '@/components/RbCard/Card'
import RadioGroupButton from '@/components/RadioGroupButton'
/**
* ImplicitDetail Component - Displays user's implicit memory profile
@@ -75,24 +77,46 @@ const ImplicitDetail = forwardRef<{ handleRefresh: () => void; }, { refresh: ()
handleRefresh
}));
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 ref={preferencesRef} />
const [activeTab, setActiveTab] = useState('preferences')
const handleChangeTab = (value: unknown) => {
setActiveTab(value as string)
}
<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">
return (
<div className="rb:h-full">
<Row gutter={12}>
<Col span={12}>
<Portrait ref={portraitRef} />
<RbCard
title={t('implicitDetail.subconscious')}
headerType="borderless"
headerClassName="rb:min-h-[50px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)]"
className="rb:h-[calc(100vh-84px)]!"
>
<RadioGroupButton
value={activeTab}
options={[
{ value: 'preferences', label: t('implicitDetail.preferences') },
{ value: 'portrait', label: t('implicitDetail.portrait') },
]}
onChange={handleChangeTab}
/>
<div className="rb:mt-3 rb:h-[calc(100%-32px)]">
{activeTab === 'preferences'
? <Preferences ref={preferencesRef} />
: <div className="rb:h-full rb:overflow-y-auto">
<Portrait ref={portraitRef} />
<InterestAreas ref={interestAreasRef} />
</div>
}
</div>
</RbCard>
</Col>
<Col span={12}>
<InterestAreas ref={interestAreasRef} />
<Habits ref={habitsRef} />
</Col>
</Row>
<Habits ref={habitsRef} />
</div>
)
})

View File

@@ -1,32 +1,35 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-08 19:46:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:09:12
*/
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'
/**
* PerceptualDetail Two-column view of a user's perceptual memory.
*
* Left column (fixed 480px): real-time sensory dashboard showing the latest
* visual, auditory and text perception streams (PerceptualLastInfo).
* Right column (fluid): chronological perception timeline (Timeline).
*
* Route param `id` (consumed by child components) identifies the end-user.
*/
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>
<Row gutter={12}>
<Col flex="480px">
<PerceptualLastInfo />
</Col>
<Col flex="1">
<Timeline />
</Col>
</Row>
)
}
export default PerceptualDetail

View File

@@ -1,40 +1,78 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-08 19:46:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:09:49
*/
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Space, Skeleton } from 'antd'
import { Space, Skeleton, Row, Col, Flex, Tooltip } from 'antd'
import clsx from 'clsx'
import {
getShortTerm,
} from '@/api/memory'
import Empty from '@/components/Empty'
import Markdown from '@/components/Markdown'
import RbCard from '@/components/RbCard/Card'
/** A single deep-retrieval entry: the original query and its retrieved passages. */
interface ShortTermItem {
retrieval: Array<{ query: string; retrieval: string[]; }>;
/** The user's original message that triggered the retrieval. */
message: string;
/** The generated answer based on retrieved passages. */
answer: string;
}
/** A candidate entry waiting to be promoted into long-term memory. */
interface LongTermItem {
query: string;
/** Aggregated retrieval text (may contain newlines). */
retrieval: string;
}
/** Combined API response for the short-term memory page. */
interface ShortData {
short_term: ShortTermItem[];
long_term: LongTermItem[];
/** Total number of extracted entities. */
entity: number;
/** Total retrieval count. */
retrieval_number: number;
/** Number of long-term memory candidates. */
long_term_number: number;
}
/**
* ShortTermDetail Displays the AI system's short-term "workbench" memory.
*
* Layout:
* - Top-left: three KPI cards (retrieval count, extracted entities, long-term candidates).
* - Left column: deep-retrieval entries each shows the user message, expandable
* sub-queries with retrieved passages, and the generated answer.
* - Right column: long-term candidate pool aggregated entries waiting to be
* promoted into persistent memory, with expand/collapse for long content.
*
* Route param `id` is the end-user ID.
*/
const ShortTermDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<ShortData>({} as ShortData)
/** Tracks expand/collapse state for each long-term candidate card by index. */
const [longTermExpandedMap, setLongTermExpandedMap] = useState<Record<number, boolean>>({})
/** Tracks expand/collapse state for short-term sub-queries and answers by composite key. */
const [shortTermExpandedMap, setShortTermExpandedMap] = useState<Record<string, boolean>>({})
/* Fetch data whenever the route user ID changes. */
useEffect(() => {
if (!id) return
getData()
}, [id])
/** Load short-term memory data (deep retrieval + long-term candidates) for the current user. */
const getData = () => {
if (!id) return
setLoading(true)
@@ -49,71 +87,154 @@ const ShortTermDetail: FC = () => {
}
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">
<Row gutter={12}>
<Col span={12}>
<div className="rb:grid rb:grid-cols-3 rb:gap-3 rb:mb-3">
{(['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>
<Flex key={key} align="center" justify="space-between" className="rb:bg-white rb:rounded-xl rb:py-3! rb:pl-5! rb:pr-4!">
<div>
<div className="rb:text-[24px] rb:leading-8 rb:mb-1">{(data as any)[key] ?? 0}</div>
{t(`shortTermDetail.${key}`)}
</div>
{key === 'retrieval_number'
? <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/retrieval_number.svg)]"></div>
: key === 'entity'
? <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/entity.svg')]"></div>
: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/long_term_number.svg')]"></div>
}
</Flex>
))}
</div>
</div>
<RbCard
title={() => (<Space size={4}>
{t('shortTermDetail.shortTermTitle')}
<Tooltip title={t('shortTermDetail.shortTermSubTitle')}>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/userMemory/question.svg')]"></div>
</Tooltip>
</Space>)}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)] rb:overflow-y-auto!"
className="rb:h-[calc(100vh-183px)]!"
>
<Flex gap={12} vertical>
{loading
? <Skeleton active />
: !data.short_term || data.short_term.length === 0
? <Empty />
: data.short_term?.map((vo, voIdx) => (
<Flex key={voIdx} gap={12} vertical className="rb:leading-5 rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3!">
<div className="rb:font-medium rb:text-[#212332] rb:leading-5">{vo.message}</div>
{vo.retrieval.map((item, index) => {
const key = `${voIdx}-${index}`
const expanded = shortTermExpandedMap[key]
return (
<div key={index} className="rb:bg-white rb:rounded-md rb:px-3 rb:py-2.5 rb:leading-5">
<Flex
align="center"
justify="space-between"
className={clsx("rb:font-medium rb:cursor-pointer", {
'rb:mb-2!': expanded,
})}
onClick={() => setShortTermExpandedMap(prev => ({ ...prev, [key]: !prev[key] }))}
>
<span>{t('shortTermDetail.query')}{index + 1}</span>
<div className={clsx("rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/down.svg')]", {
'rb:rotate-180': expanded
})}></div>
</Flex>
{expanded && (
<>
<div className="rb:text-[#5B6167] rb:mb-4">
<Markdown content={item.query} />
</div>
<div className="rb:font-medium rb:mb-2">{t('shortTermDetail.answer')}</div>
{item.retrieval.length > 0
? <ul className="rb:list-disc rb:ml-4 rb:text-[#5B6167]">
{item.retrieval.map((retrieval, retrievalIdx) => (
<li key={retrievalIdx} className="rb:text-[#5B6167]">{retrieval}</li>
))}
</ul>
: <div className="rb:text-[#5B6167]">{t('shortTermDetail.noAnswer')}</div>
}
</>
)}
</div>
)
})}
<div className="rb:leading-5 rb:bg-white rb:rounded-xl rb:p-3!">
<Flex
align="center"
justify="space-between"
className={clsx("rb:font-medium rb:cursor-pointer", {
'rb:mb-2!': shortTermExpandedMap[`ans-${voIdx}`],
})}
onClick={() => setShortTermExpandedMap(prev => ({ ...prev, [`ans-${voIdx}`]: !prev[`ans-${voIdx}`] }))}
>
<span>{t('shortTermDetail.answer')}</span>
<div className={clsx("rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/down.svg')]", {
'rb:rotate-180': shortTermExpandedMap[`ans-${voIdx}`]
})}></div>
</Flex>
{shortTermExpandedMap[`ans-${voIdx}`] && (
<div className="rb:text-[#5B6167]">
<Markdown content={vo.answer} />
</div>
)}
</div>
</Flex>
))
}
</Flex>
</RbCard>
</Col>
<Col span={12}>
<RbCard
title={() => (<Space size={4}>
{t('shortTermDetail.longTermTitle')}
<Tooltip title={t('shortTermDetail.longTermTitleSubTitle')}>
<div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/userMemory/question.svg')]"></div>
</Tooltip>
</Space>)}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)] rb:overflow-y-auto!"
className="rb:h-[calc(100vh-88px)]!"
>
<Flex vertical gap={12}>
{loading
? <Skeleton active />
: !data.long_term || data.long_term.length === 0
? <Empty />
: data.long_term?.map((vo, voIdx) => {
const lines = vo.retrieval.split('\n')
const expanded = longTermExpandedMap[voIdx]
return (
<div key={voIdx} className="rb:leading-5 rb:bg-[#F6F6F6] rb:rounded-xl rb:p-3">
<div className="rb:mb-3 rb:text-[#212332] rb:font-medium rb:px-1">{vo.query}</div>
<div className="rb:bg-white rb:rounded-xl rb:px-3 rb:py-2.5">
<div className={expanded ? undefined : "rb:wrap-break-word rb:line-clamp-3"}>
<Markdown content={vo.retrieval} />
</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>}
{lines.length > 4 && (
<div
className="rb:text-[#155EEF] rb:cursor-pointer rb:mt-1"
onClick={() => setLongTermExpandedMap(prev => ({ ...prev, [voIdx]: !prev[voIdx] }))}
>
{expanded ? t('common.foldUp') : t('common.expanded')}
</div>
)}
</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">
<Markdown content={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]">
<Markdown content={vo.retrieval} />
</div>
</div>
))
}
</Space>
</div>
)
})
}
</Flex>
</RbCard>
</Col>
</Row>
)
}
export default ShortTermDetail

View File

@@ -1,11 +1,11 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-19 16:54:52
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 16:28:00
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:06:29
*/
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { Row, Col, Space } from 'antd';
import { Row, Col } from 'antd';
import { useParams } from 'react-router-dom'
import WordCloud from '../components/WordCloud'
@@ -46,13 +46,19 @@ const StatementDetail = forwardRef<{ handleRefresh: () => void },{ refresh: () =
handleRefresh
}));
return (
<Row gutter={[16, 16]}>
<Row gutter={[12, 12]}>
<Col span={12}>
<Space size={16} direction="vertical" className="rb:w-full">
<WordCloud />
<EmotionTags />
<Health />
</Space>
<Row gutter={[12, 12]}>
<Col span={24}>
<WordCloud />
</Col>
<Col span={12}>
<EmotionTags />
</Col>
<Col span={12}>
<Health />
</Col>
</Row>
</Col>
<Col span={12}>
<Suggestions ref={suggestionsRef} refresh={refresh} />

View File

@@ -1,8 +1,15 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-12 14:42:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 15:10:17
*/
import { type FC, useEffect, useState, useMemo } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Skeleton, Button, Divider, Tooltip } from 'antd'
import { Row, Col, Skeleton, Button, Divider, Tooltip, Flex } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getConversations,
@@ -10,25 +17,45 @@ import {
getConversationDetail,
} from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import RbAlert from '@/components/RbAlert'
import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types'
import PageLoading from '@/components/Empty/PageLoading'
/** A conversation session entry in the sidebar list. */
interface Conversation {
title: string;
id: string;
}
/**
* AI-generated insight for a conversation, including key takeaways,
* open questions, and an overall summary.
*/
interface Detail {
theme: string;
theme_intro: string;
/** Core insight summary of the conversation. */
summary: string;
/** Open questions or pitfalls identified during the conversation. */
question: string[];
/** Successful experiences / key takeaways extracted from the conversation. */
takeaways: string[];
/** Information quality score. */
info_score: number;
}
/**
* WorkingDetail Three-column working-memory view for a user's conversations.
*
* Left column (360px): scrollable list of conversation sessions.
* Centre column (fluid): real-time chat message stream for the selected conversation,
* with a refresh button and time-range indicator.
* Right column (360px): AI-generated conversation insights successful experiences
* (takeaways), open questions / pitfalls, and a core summary.
*
* Route param `id` is the end-user ID.
*/
const WorkingDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
@@ -40,11 +67,13 @@ const WorkingDetail: FC = () => {
const [detail, setDetail] = useState<Detail | null>(null)
const [selected, setSelected] = useState<Conversation | null>(null)
/* Fetch conversation list whenever the route user ID changes. */
useEffect(() => {
if (!id) return
getData()
}, [id])
/** Load all conversations for the current user and auto-select the first one. */
const getData = () => {
if (!id) return
setLoading(true)
@@ -61,11 +90,16 @@ const WorkingDetail: FC = () => {
})
}
/* Load messages and AI insight whenever the selected conversation changes. */
useEffect(() => {
if (!id || !selected || !selected.id) return
getDetail(selected.id)
}, [id, selected])
/**
* Fetch both the chat messages and the AI-generated insight for a conversation.
* Both requests run in parallel.
*/
const getDetail = (conversationId: string) => {
if (!id || !conversationId) return
@@ -88,6 +122,7 @@ const WorkingDetail: FC = () => {
setDetailLoading(false)
})
}
/** Derive a human-readable date range (e.g. "2024.01 - 2024.03") from message timestamps. */
const timeRange = useMemo(() => {
const times = messages.filter(m => m.created_at).map(m => Number(m.created_at))
if (times.length === 0) return ''
@@ -97,113 +132,126 @@ const WorkingDetail: FC = () => {
}, [messages])
return (
<div className="rb:h-[calc(100vh-64px)]! rb:w-full rb:-mx-4 rb:-my-3">
<>
{loading
? <PageLoading />
: data.length === 0
? <Empty />
:(
<Row gutter={16} className="rb:h-full">
<Col span={5}>
<div className="rb:h-full! rb:border-r rb:border-[#EAECEE] rb:py-3 rb:px-4">
{data.map(item => (
<div key={item.id} className="rb:mb-3">
<Tooltip title={item.title}>
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap", {
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === selected?.id,
<Col flex='360px' className="rb:h-full">
<RbCard
title={t('workingDetail.conversation')}
headerType="borderless"
headerClassName="rb:min-h-[54px]! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName='rb:p-3! rb:pt-0! rb:h-[calc(100%-54px)]'
className="rb:h-full!"
>
<Flex gap={8} vertical>
{data.map(item => (
<Flex
key={item.id}
gap={12}
align="center"
className={clsx("rb:cursor-pointer rb:rounded-xl rb:h-12 rb:py-1! rb:px-3! rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#171719] rb:hover:bg-[#171719]! rb:text-white': item.id === selected?.id,
})}
onClick={() => setSelected(item)}
>
{item.title}
</div>
</Tooltip>
</div>
))}
</div>
onClick={() => setSelected(item)}
>
<div className="rb:size-6 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/chat.svg')]"></div>
<Tooltip title={item.title}>
<div className="rb:leading-5 rb:wrap-break-word rb:line-clamp-2 rb:flex-1">
{item.title}
</div>
</Tooltip>
</Flex>
))}
</Flex>
</RbCard>
</Col>
{selected && <>
<Col span={19}>
<div className="rb:text-[18px] rb:font-medium rb:leading-6 rb:mt-4">{selected.title}</div>
<div className="rb:mt-1 rb:text-[#5B6167] rb:leading-5">{timeRange}</div>
<Col flex="auto" className="rb:h-full">
<RbCard
title={selected.title}
headerType="borderless"
headerClassName="rb:min-h-[42px]! rb:pt-4! rb:font-[MiSans-Bold] rb:font-bold"
bodyClassName='rb:p-4! rb:pt-0! rb:h-[calc(100%-42px)]'
className="rb:h-full!"
>
<div className="rb:text-[#5B6167] rb:leading-4.5 rb:text-[12px]">{timeRange}</div>
<Flex justify="space-between" align="center" className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2.5! rb:pr-2.5! rb:pl-3.25! rb:mt-3!">
{t('workingDetail.conversationStream')}
<Button className="rb:h-6!" onClick={() => getDetail(selected.id)}>{t('workingDetail.refresh')}</Button>
</Flex>
{messagesLoading
? <Skeleton active />
: messages.length === 0
? <Empty />
: (
<ChatContent
classNames="rb:h-[calc(100%-77px)] rb:pt-5"
contentClassNames="rb:max-w-110!"
data={messages}
streamLoading={false}
labelFormat={(item) => formatDateTime(item.created_at)}
/>
)
}
</RbCard>
</Col>
<Col flex='360px' className="rb:h-full">
<RbCard
title={t('workingDetail.successfulTitle')}
headerType="borderless"
headerClassName="rb:min-h-[50px]! rb:font-[MiSans-Bold] rb:font-bold rb:leading-5.5"
bodyClassName='rb:p-4! rb:pt-0! rb:h-[calc(100%-50px)] rb:overflow-y-auto!'
className="rb:h-full!"
>
{detailLoading
? <Skeleton active />
: detail
? <>
{detail.takeaways.length > 0
? (
<ul className="rb:leading-5 rb:list-disc rb:ml-4">
{detail.takeaways.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
<Row gutter={16}>
<Col span={16}>
<RbCard
title={t('workingDetail.conversationStream')}
extra={<Button className="rb:h-6!" onClick={() => getDetail(selected.id)}>{t('workingDetail.refresh')}</Button>}
className="rb:mt-4!"
headerClassName='rb:bg-[#F6F8FC]! rb:border-b! rb:border-b-[#DFE4ED]! rb:min-h-11!'
headerType="borderless"
bodyClassName="rb:h-[calc(100vh-210px)]"
>
{messagesLoading
? <Skeleton active />
: messages.length === 0
? <Empty />
: (
<ChatContent
classNames="rb:h-[calc(100vh-244px)]"
data={messages}
streamLoading={false}
labelFormat={(item) => formatDateTime(item.created_at)}
/>
)
}
</RbCard>
</Col>
<Col span={8}>
<RbCard className="rb:mt-4!" bodyClassName="rb:h-[calc(100vh-166px)] rb:overflow-y-auto">
{detailLoading
? <Skeleton active />
: detail
? <>
<>
<div className="rb:text-[#369F21] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.successfulTitle')}</div>
<>
<Divider className="rb:my-4!" />
<div className="rb:font-[MiSans-Bold] rb:font-bold rb:text-[16px] rb:leading-5.5 rb:mb-3">{t('workingDetail.question')}</div>
{detail.takeaways.length > 0
? (
<ul className="rb:text-[#5B6167] rb:leading-5.5 rb:list-disc rb:ml-4">
{detail.takeaways.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
</>
<>
<Divider />
<div className="rb:text-[#FF5D34] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.question')}</div>
{detail.question.length > 0
? (
<ul className="rb:text-[#5B6167] rb:leading-5.5 rb:list-disc rb:ml-4">
{detail.question.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
</>
<>
<Divider />
<div className="rb:text-[#369F21] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.summary')}</div>
{detail.summary
? <RbAlert className="rb:text-[#212332]! rb:text-[14px]! rb:leading-5.5! rb:p-3!">{detail.summary}</RbAlert>
: <Empty size={88} />
}
</>
{detail.question.length > 0
? (
<ul className="rb:leading-5 rb:list-disc rb:ml-4">
{detail.question.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
</>
: <Empty />
}
</RbCard>
</Col>
</Row>
<>
<Divider className="rb:my-4!" />
<div className="rb:font-[MiSans-Bold] rb:font-bold rb:text-[16px] rb:leading-5.5 rb:mb-3">{t('workingDetail.summary')}</div>
{detail.summary
? <div className="rb:leading-5.5">{detail.summary}</div>
: <Empty size={88} />
}
</>
</>
: <Empty />
}
</RbCard>
</Col>
</>}
</Row>
)
}
</div>
</>
)
}
export default WorkingDetail

View File

@@ -0,0 +1,10 @@
.progressCustom :global(.ant-progress-inner) {
width: 64px !important;
height: 64px !important;
}
.progressCustom :global(.ant-progress-text) {
color: #155EEF !important;
font-weight: bold !important;
font-family: 'MiSans-Bold' !important;
font-size: 16px !important;
}

View File

@@ -1,15 +1,15 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-07 20:37:34
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 16:27:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 11:22:20
*/
import { type FC, useEffect, useState, useMemo, useRef } from 'react'
import { type FC, useState, useMemo, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Dropdown, Button } from 'antd'
import { Dropdown, Button, Flex, Space } from 'antd'
import PageHeader from '../components/PageHeader'
import PageHeader from '@/components/Layout/PageHeader'
import StatementDetail from './StatementDetail'
import ForgetDetail from './ForgetDetail'
import ImplicitDetail from './ImplicitDetail'
@@ -18,10 +18,6 @@ import PerceptualDetail from './PerceptualDetail'
import EpisodicDetail from './EpisodicDetail'
import ExplicitDetail from './ExplicitDetail'
import WorkingDetail from './WorkingDetail'
import {
getEndUserProfile,
} from '@/api/memory'
import refreshIcon from '@/assets/images/refresh_hover.svg'
import GraphDetail from './GraphDetail'
/**
@@ -32,26 +28,11 @@ const Detail: FC = () => {
const { t } = useTranslation()
const { id, type } = useParams()
const navigate = useNavigate()
const [name, setName] = useState<string>('')
// Refs for child components that support imperative refresh
const forgetDetailRef = useRef<{ handleRefresh: () => void }>(null)
const statementDetailRef = useRef<{ handleRefresh: () => void }>(null)
const implicitDetailRef = useRef<{ handleRefresh: () => void }>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
// Fetch end user profile to display the user's name in the header
const getData = () => {
if (!id) return
getEndUserProfile(id).then((res) => {
const response = res as { other_name: string; id: string; }
setName(response.other_name || response.id)
})
}
// Build dropdown menu items for switching between memory types
const items = useMemo(() => {
return ['PERCEPTUAL_MEMORY', 'WORKING_MEMORY', 'EMOTIONAL_MEMORY', 'SHORT_TERM_MEMORY', 'IMPLICIT_MEMORY', 'EPISODIC_MEMORY', 'EXPLICIT_MEMORY', 'FORGET_MEMORY']
@@ -94,29 +75,46 @@ const Detail: FC = () => {
if (type === 'GRAPH') {
return <GraphDetail />
}
const handleGoBack = () => {
navigate(`/user-memory/neo4j/${id}`, { replace: true })
}
return (
<div className="rb:h-full rb:w-full">
<PageHeader
name={name}
source="node"
operation={
<PageHeader
title={
<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}`) : ''}
<Flex align="center" gap={8} className="rb:font-[MiSans-Bold] rb:font-bold rb:text-[16px] rb:leading-6 rb:cursor-pointer rb:group">
{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)]"
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/up_border.svg')] rb:group-hover:transform-[rotate(180deg)]"
></div>
</div>
</Flex>
</Dropdown>
}
extra={['FORGET_MEMORY', 'EMOTIONAL_MEMORY', 'IMPLICIT_MEMORY'].includes(type as string) &&
<Button type="primary" ghost size="small" className="rb:h-6! rb:px-2! rb:leading-5.5!" loading={loading} onClick={handleRefresh}>
{!loading && <img src={refreshIcon} className="rb:w-4 rb:h-4" /> }
{t('common.refresh')}
</Button>}
extra={
<Space size={12}>
{['FORGET_MEMORY', 'EMOTIONAL_MEMORY', 'IMPLICIT_MEMORY'].includes(type as string) &&
<Button
className="rb:px-2! rb:gap-0.5!"
loading={loading}
icon={<div className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh_dark.svg')]"></div>}
onClick={handleRefresh}
>
{t('common.refresh')}
</Button>
}
<Button
className="rb:px-2! rb:gap-0.5!"
icon={<div className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/return.svg')]"></div>}
onClick={handleGoBack}
>
{t('common.return')}
</Button>
</Space>
}
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:p-3">
{type === 'EMOTIONAL_MEMORY' && <StatementDetail ref={statementDetailRef} refresh={handleRefresh} />}
{type === 'FORGET_MEMORY' && <ForgetDetail ref={forgetDetailRef} />}
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail ref={implicitDetailRef} refresh={handleRefresh} />}