feat: Add base project structure with API and web components

This commit is contained in:
Ke Sun
2025-12-02 20:28:01 +08:00
parent f3de6d6cc9
commit c1adc62ec6
817 changed files with 111226 additions and 106 deletions

View File

@@ -0,0 +1,23 @@
import { type FC, type ReactNode } from 'react'
import clsx from 'clsx'
interface CardProps {
title?: string;
children: ReactNode;
theme?: 'default' | 'custom';
className?: string;
}
const Card: FC<CardProps> = ({ title, children, theme = 'default', className }) => {
return (
<div className={clsx('rb:h-full rb:border rb:rounded-[12px] rb:p-[16px] rb:border-[#DFE4ED]', {
'rb:bg-[#FBFDFF]': theme === 'default',
'rb:bg-[linear-gradient(180deg,_#F1F9FE_0%,_#FBFCFF_100%)]': theme === 'custom',
}, className)}>
{title && <div className="rb:text-[18px] rb:font-semibold rb:leading-[25px] rb:pb-[16px]">{title}</div>}
{children}
</div>
)
}
export default Card

View File

@@ -0,0 +1,62 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import { List } from 'antd';
import Markdown from '@/components/Markdown'
import {
getRagContent
} from '@/api/memory'
const ConversationMemory:FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(true)
const [list, setList] = useState<string[]>([])
useEffect(() => {
if (!id) return
getList()
}, [id])
const getList = () => {
if (!id) return
setLoading(true)
getRagContent(id).then((res) => {
setList((res as { contents?: [] }).contents || [])
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
title={t('userMemory.conversationMemory')}
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName="rb:h-[100%]! rb:overflow-hidden rb:py-0!"
>
{loading
? <Skeleton />
: list.length > 0
? <List
dataSource={list}
grid={{ gutter: 12, column: 1 }}
renderItem={(item, index) => (
<List.Item>
<div
key={index}
className="rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:rounded-lg rb:mt-2 rb:text-gray-800 rb:text-sm"
>
<Markdown content={item} />
</div>
</List.Item>
)}
/>
: <Empty className="rb:h-full" />
}
</RbCard>
)
}
export default ConversationMemory

View File

@@ -0,0 +1,55 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import {
getMemoryInsightReport,
} from '@/api/memory'
const MemoryInsight:FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [report, setReport] = useState<string | null>(null)
useEffect(() => {
if (!id) return
getInsightReport()
}, [id])
// 记忆洞察
const getInsightReport = () => {
if (!id) return
setLoading(true)
getMemoryInsightReport(id).then((res) => {
setReport((res as { report?: string }).report || null)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
title={t('userMemory.memoryInsight')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bgColor="linear-gradient(180deg,#F1F9FE 0%, #FBFCFF 100%)"
height="100%"
>
{loading
? <Skeleton />
: report
? <div className="rb:flex rb:flex-wrap rb:justify-between rb:h-full">
<div className="rb:leading-[22px]">
{report|| '-'}
</div>
</div>
: <Empty size={80} />
}
</RbCard>
)
}
export default MemoryInsight

View File

@@ -0,0 +1,128 @@
import { type FC, useRef, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import ReactEcharts from 'echarts-for-react';
import { getHotMemoryTagsByUser } from '@/api/memory';
import Empty from '@/components/Empty';
import Loading from '@/components/Empty/Loading';
const Colors = ['#155EEF', '#4DA8FF', '#03BDFF', '#31E8FF', '#AD88FF', '#FFB048']
const PieCard: FC = () => {
const { id } = useParams()
const chartRef = useRef<ReactEcharts>(null);
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Array<Record<string, string | number>>>([])
useEffect(() => {
getData()
}, [id])
const getData = () => {
setLoading(true)
getHotMemoryTagsByUser(id as string).then(res => {
const response = res as { name: string; frequency: number }[]
setData(response.map(item => ({
...item,
value: item.frequency,
})))
})
.finally(() => {
setLoading(false)
})
}
return (
<>
{loading
? <Loading size={249} />
: !data || data.length === 0
? <Empty size={88} className="rb:mt-[48px] rb:mb-[81px]" />
: data && data.length > 0 &&
<ReactEcharts
option={{
color: Colors,
tooltip: {
show: false,
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: {
type: data.length > 8 ? 'scroll' : 'plain',
bottom: 0,
left: 16,
padding: 0,
itemWidth: 12,
itemHeight: 12,
borderRadius: 2,
// orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['60%', '100%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 0,
width: 220,
height: 220,
top: 32,
left: 'center',
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: data
}
]
}}
style={{ height: '340px', width: '100%' }}
notMerge={true}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
}
</>
)
}
export default PieCard

View File

@@ -0,0 +1,222 @@
import React, { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Col } from 'antd'
import RbCard from '@/components/RbCard/Card'
import ReactEcharts from 'echarts-for-react'
import zoom from '@/assets/images/userMemory/zoom.svg'
import drag from '@/assets/images/userMemory/drag.svg'
import pointer from '@/assets/images/userMemory/pointer.svg'
import empty from '@/assets/images/userMemory/empty.svg'
import type { EdgeData, Node, Edge } from '../types'
import {
getMemorySearchEdges,
} from '@/api/memory'
import Empty from '@/components/Empty'
const operations = [
{ name: 'click', icon: pointer },
{ name: 'drag', icon: drag },
{ name: 'zoom', icon: zoom },
]
const RelationshipNetwork:FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const chartRef = useRef<ReactEcharts>(null)
const [nodes, setNodes] = useState<Node[]>([])
const [links, setLinks] = useState<Edge[]>([])
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
useEffect(() => {
if (!id) return
getEdgeData()
}, [id])
// 关系网络
const getEdgeData = () => {
if (!id) return
setSelectedNode(null)
getMemorySearchEdges(id).then((res) => {
const list = (res as { detials?: EdgeData[] }).detials || []
const nodes: Node[] = [];
const links: Edge[] = [];
const categories: { name: string }[] = []
list.forEach(item => {
if (item.edge) {
links.push({
...item.edge,
target: item.edge?.target_id,
source: item.edge?.source_id,
})
}
if (item.sourceNode) {
nodes.push(item.sourceNode)
categories.push({name: item.sourceNode.entity_type})
}
if (item.targetNode) {
nodes.push(item.targetNode)
categories.push({name: item.targetNode.entity_type})
}
})
// 根据ID字段去重节点
const uniqueNodes = nodes.filter((node, index, self) =>
index === self.findIndex((n) => n.id === node.id && n.name === node.name)
)
const uniqueLinks = links.filter((node, index, self) =>
index === self.findIndex((n) => n.target === node.target && n.source === node.source)
)
const uniqueCategories = categories.filter((node, index, self) =>
index === self.findIndex((n) => n.name === node.name)
)
setLinks(uniqueLinks)
setCategories(uniqueCategories)
uniqueNodes.map(item => {
const index = uniqueCategories.findIndex((n) => n.name === item.entity_type)
item.category = index
item.symbolSize = index < 10 ? 5 : index <100 ? 8 : 10
})
setNodes(uniqueNodes)
})
}
return (
<>
{/* 关系网络 */}
<Col span={24}>
<RbCard
title={t('userMemory.relationshipNetwork')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
<div className="rb:h-[496px]">
{nodes.length === 0 ? (
<Empty className="rb:h-full" />
) : (
<ReactEcharts
option={{
colors: ['#155EEF', '4DA8FF', '#9C6FFF', '#8BAEF7', '#369F21', '#FF5D34', '#FF8A4C', '#FFB048'],
tooltip: {
show: false
},
series: [
{
type: 'graph',
layout: 'force',
data: nodes || [],
links: links || [],
categories: categories || [],
roam: true,
lineStyle: {
color: 'source',
curveness: 0.3
},
force: {
repulsion: 100,
// 启用类别聚合
edgeLength: 80,
gravity: 0.3,
// 同类别的节点相互吸引
layoutAnimation: true,
// 防止点击时重新计算布局
preventOverlap: true,
// 点击节点后保持布局稳定
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [4, 10],
// 初始布局完成后关闭力导向
initLayout: 'force'
},
selectedMode: 'single',
draggable: true,
// 防止数据更新时重新计算布局
animationDurationUpdate: 0,
select: {
itemStyle: {
borderWidth: 2,
borderColor: '#ffffff',
shadowBlur: 10,
}
}
}
]
}}
style={{ height: '496px', width: '100%' }}
notMerge={false}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
},
// 节点点击事件处理
click: (params: { dataType: string; data: Node }) => {
if (params.dataType === 'node') {
// 处理节点点击事件
console.log('Node clicked:', params.data);
setSelectedNode(params.data)
if (selectedNode?.id === params.data.id) {
setSelectedNode(null)
} else {
setSelectedNode(params.data)
}
}
}
}}
/>
)}
</div>
<div className="rb:bg-[#F0F3F8] rb:flex rb:items-center rb:gap-[24px] rb:rounded-[0px_0px_12px_12px] rb:p-[14px_40px] rb:m-[0_-20px_-16px_-16px]">
{operations.map((item) => (
<div key={item.name} className="rb:flex rb:items-center rb:text-[#5B6167] rb:leading-[20px]">
<img src={item.icon} className="rb:w-[20px] rb:h-[20px] rb:mr-[4px]" />
{t(`userMemory.${item.name}`)}
</div>
))}
</div>
</RbCard>
</Col>
{/* 记忆详情 */}
<Col span={24}>
<RbCard
title={t('userMemory.memoryDetails')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
>
{(!selectedNode || (!selectedNode?.description && !selectedNode?.entity_type))
? <Empty
url={empty}
title={t('userMemory.memoryDetailEmpty')}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:mb-[12px]"
size={88}
/>
: <>
{selectedNode?.description &&
<div className="rb:font-medium rb:mb-[8px]">
{t('userMemory.description')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]"> {selectedNode.description}</div>
</div>
}
{selectedNode?.entity_type &&
<div className="rb:font-medium rb:mb-[8px]">
{t('userMemory.entityType')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[8px]"> {selectedNode.entity_type}</div>
</div>
}
</>
}
</RbCard>
</Col>
</>
)
}
// 使用React.memo包装组件避免不必要的渲染
export default React.memo(RelationshipNetwork)