372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
/*
|
|
* @Author: ZhaoYing
|
|
* @Date: 2026-02-03 17:09:03
|
|
* @Last Modified by: ZhaoYing
|
|
* @Last Modified time: 2026-04-20 16:59:25
|
|
*/
|
|
/**
|
|
* Memory Conversation Page
|
|
* Interactive conversation interface with memory analysis
|
|
* Supports deep thinking, normal reply, and quick reply modes
|
|
*/
|
|
|
|
import { type FC, type ReactNode, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Col, Row, App, Skeleton, Segmented, Tooltip, Flex, Image } from 'antd'
|
|
import dayjs from 'dayjs'
|
|
import type { AnyObject } from 'antd/es/_util/type';
|
|
|
|
import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg'
|
|
import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.png'
|
|
import { readService, userMemoryListUrl } from '@/api/memory'
|
|
import Empty from '@/components/Empty'
|
|
import DebounceSelect from '@/components/DebounceSelect'
|
|
import Markdown from '@/components/Markdown'
|
|
import type { Data } from '@/views/UserMemory/types'
|
|
import type { DefaultOptionType } from 'antd/es/select'
|
|
import Chat from '@/components/Chat'
|
|
import type { ChatItem } from '@/components/Chat/types'
|
|
import RbCard from '@/components/RbCard/Card';
|
|
import styles from './index.module.css'
|
|
import ResultCard from '@/components/RbCard/ResultCard'
|
|
import AudioPlayer from '@/views/UserMemoryDetail/components/AudioPlayer'
|
|
import VideoPlayer from '@/views/UserMemoryDetail/components/VideoPlayer'
|
|
|
|
|
|
/** Search mode configuration */
|
|
const searchSwitchList = [
|
|
{
|
|
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/deepThinking.svg')]"></div>,
|
|
value: '0',
|
|
key: 'deepThinking'
|
|
},
|
|
{
|
|
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/normalReply.svg')]"></div>,
|
|
value: '1',
|
|
key: 'normalReply'
|
|
},
|
|
{
|
|
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/quickReply.svg')]"></div>,
|
|
value: '2',
|
|
key: 'quickReply'
|
|
},
|
|
]
|
|
|
|
/**
|
|
* Test parameters for conversation API
|
|
*/
|
|
export interface TestParams {
|
|
/** End user identifier */
|
|
end_user_id: string;
|
|
/** User message content */
|
|
message: string;
|
|
/** Search mode switch (0: deep thinking, 1: normal, 2: quick) */
|
|
search_switch: string;
|
|
/** Conversation history */
|
|
history: { role: string; content: string }[];
|
|
/** Enable web keyword */
|
|
web_search?: boolean;
|
|
/** Enable memory function */
|
|
memory?: boolean;
|
|
/** Conversation ID */
|
|
conversation_id?: string;
|
|
}
|
|
/**
|
|
* Data item in analysis logs
|
|
*/
|
|
interface DataItem {
|
|
id: string;
|
|
question: string;
|
|
type: string;
|
|
reason?: string;
|
|
}
|
|
/**
|
|
* Log item for conversation analysis
|
|
*/
|
|
export interface LogItem {
|
|
type: string;
|
|
title: string;
|
|
data?: DataItem[] | AnyObject;
|
|
raw_results?: string | Record<string, AnyObject>;
|
|
raw_result?: Array<AnyObject>;
|
|
summary?: string;
|
|
query?: string;
|
|
reason?: string;
|
|
result?: string;
|
|
original_query?: string;
|
|
index?: number;
|
|
result_count?: number;
|
|
total?: number;
|
|
}
|
|
|
|
/**
|
|
* Content wrapper component for analysis items
|
|
*/
|
|
const ContentWrapper: FC<{ children: ReactNode }> = ({ children }) => (
|
|
<div className="rb:px-3 rb:py-2.5 rb:bg-white rb:rounded-xl">
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
const MemoryConversation: FC = () => {
|
|
const { t } = useTranslation()
|
|
const { message } = App.useApp();
|
|
const [userId, setUserId] = useState<string>()
|
|
const [loading, setLoading] = useState<boolean>(false)
|
|
const [chatData, setChatData] = useState<ChatItem[]>([])
|
|
const [logs, setLogs] = useState<LogItem[]>([])
|
|
const [search_switch, setSearchSwitch] = useState('0')
|
|
const [msg, setMsg] = useState<string>('')
|
|
const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({})
|
|
|
|
/** Handle message send */
|
|
const handleSend = () => {
|
|
if(!userId) {
|
|
message.warning(t('common.inputPlaceholder', { title: t('memoryConversation.userID') }))
|
|
return
|
|
}
|
|
setChatData(prev => [...prev, { content: msg, created_at: new Date().getTime(), role: 'user' }])
|
|
setLoading(true)
|
|
setExpandedLogs({})
|
|
readService({
|
|
message: msg,
|
|
end_user_id: userId,
|
|
search_switch: search_switch,
|
|
history: [],
|
|
})
|
|
.then(res => {
|
|
const response = res as { answer: string; intermediate_outputs: LogItem[] }
|
|
setChatData(prev => [...prev, { content: response.answer || '-', created_at: new Date().getTime(), role: 'assistant' }])
|
|
setLogs(response.intermediate_outputs)
|
|
setExpandedLogs(Object.fromEntries(response.intermediate_outputs.map((_, i) => [i, true])))
|
|
})
|
|
.finally(() => {
|
|
setLoading(false)
|
|
})
|
|
}
|
|
|
|
/** Handle keyword mode change */
|
|
const handleChange = (value: string) => {
|
|
setSearchSwitch(value)
|
|
}
|
|
const handleDownload = (file_path?: string) => {
|
|
if (!file_path) return
|
|
window.open(file_path, '_blank')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<DebounceSelect
|
|
url={userMemoryListUrl}
|
|
searchKey="keyword"
|
|
format={(items) => (items as Data[]).map(item => ({
|
|
...item,
|
|
'end_user.id': item.end_user?.id,
|
|
label: item.end_user?.other_name || item.end_user?.id,
|
|
value: item.end_user?.id,
|
|
}))}
|
|
placeholder={t('memoryConversation.searchPlaceholder')}
|
|
style={{ width: '100%', marginBottom: '16px' }}
|
|
onChange={(opt: DefaultOptionType) => setUserId(opt?.value as string)}
|
|
variant="borderless"
|
|
className="rb:bg-white rb:rounded-lg"
|
|
showSearch
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16} className="rb:h-[calc(100%-48px)]!">
|
|
<Col span={12} className="rb:h-full!">
|
|
<RbCard
|
|
title={t('memoryConversation.conversationContent')}
|
|
headerType="borderless"
|
|
headerClassName="rb:min-h-[52px]! rb:font-[MiSans-Bold] rb:font-bold"
|
|
bodyClassName="rb:px-3! rb:py-0! rb:h-[calc(100%-52px)]!"
|
|
className="rb:h-full!"
|
|
>
|
|
<Chat
|
|
empty={
|
|
<Empty url={ConversationEmptyIcon} className="rb:h-full" size={[140, 100]} title={t('memoryConversation.conversationContentEmpty')} isNeedSubTitle={false} />
|
|
}
|
|
className="rb:pt-0!"
|
|
contentClassName='rb:h-[calc(100%-144px)]'
|
|
data={chatData}
|
|
onChange={setMsg}
|
|
onSend={handleSend}
|
|
loading={loading}
|
|
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
|
>
|
|
<Segmented
|
|
options={searchSwitchList.map(item => ({
|
|
...item,
|
|
icon: <Tooltip title={t(`memoryConversation.${item.key}`)}>{item.icon}</Tooltip>
|
|
}))}
|
|
shape="round"
|
|
className={styles.segmented}
|
|
onChange={handleChange}
|
|
/>
|
|
</Chat>
|
|
</RbCard>
|
|
</Col>
|
|
<Col span={12} className="rb:h-full!">
|
|
<RbCard
|
|
title={t('memoryConversation.memoryConversationAnalysis')}
|
|
headerType="borderless"
|
|
headerClassName="rb:min-h-[52px]! rb:font-[MiSans-Bold] rb:font-bold"
|
|
bodyClassName="rb:p-3! rb:pt-0! rb:h-[calc(100%-52px)]! rb:overflow-y-auto!"
|
|
className="rb:h-full!"
|
|
>
|
|
{loading ?
|
|
<Skeleton active />
|
|
: !logs || logs.length === 0 ?
|
|
<Empty
|
|
url={AnalysisEmptyIcon}
|
|
className="rb:h-full"
|
|
title={t('memoryConversation.memoryConversationAnalysisEmpty')}
|
|
subTitle={t('memoryConversation.memoryConversationAnalysisEmptySubTitle')}
|
|
size={[270, 170]}
|
|
/>
|
|
: <Flex gap={12} vertical>
|
|
{logs.map((log, logIndex) => (
|
|
<ResultCard
|
|
key={logIndex}
|
|
title={log.title}
|
|
isMiSans={false}
|
|
bodyClassName={`rb:p-3! rb:pt-0! ${!!expandedLogs[logIndex] ? 'rb:pb-3!' : 'rb:pb-0!'}`}
|
|
expanded={!!expandedLogs[logIndex]}
|
|
handleExpand={() => setExpandedLogs(prev => ({ ...prev, [logIndex]: !prev[logIndex] }))}
|
|
extra={log.type === 'verification' && <div className="rb-border rb:rounded-lg rb:py-1 rb:px-2 rb:text-[12px] rb:font-medium rb:leading-4.5 rb:text-[#FF5D34]">{log.result}</div>}
|
|
>
|
|
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
|
|
? <Flex gap={12} vertical>
|
|
{log.data.map(vo => (
|
|
<ContentWrapper key={vo.id}>
|
|
<>
|
|
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
|
|
</>
|
|
</ContentWrapper>
|
|
))}
|
|
</Flex>
|
|
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
|
|
? <Flex gap={12} vertical>
|
|
{Object.keys(log.data).map((key: string) => (
|
|
<ContentWrapper key={key}>
|
|
<>
|
|
<div className="rb:font-medium rb:text-[#212332]">{key}</div>
|
|
{(log.data as Record<string, string[]>)[key].map((item, index) => (
|
|
<div key={index} className="rb:mt-2 rb:text-[#5B6167]">{item}</div>
|
|
))}
|
|
</>
|
|
</ContentWrapper>
|
|
))}
|
|
</Flex>
|
|
: log.type === 'search_result' && log.result
|
|
? <ContentWrapper>
|
|
<Markdown content={log.result} />
|
|
</ContentWrapper>
|
|
: log.type === 'retrieval_summary' && log.summary
|
|
? <ContentWrapper>
|
|
<div className="rb:text-[12px] rb:text-[#5B6167]">{log.summary}</div>
|
|
</ContentWrapper>
|
|
: log.type === 'verification'
|
|
? <ContentWrapper>
|
|
<div className="rb:font-medium rb:text-[#212332]">{log.query}</div>
|
|
<div className="rb:mt-2 rb:text-[#5B6167]">{log.reason}</div>
|
|
<div className="rb:mt-2 rb:text-[#5B6167]">{log.result}</div>
|
|
</ContentWrapper>
|
|
: log.type === 'output_type'
|
|
? <ContentWrapper>
|
|
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
|
<div className="rb:text-[12px] rb:text-[#5B6167]">{log.summary}</div>
|
|
</ContentWrapper>
|
|
: log.type === 'input_summary' && log.raw_results
|
|
? <ContentWrapper>
|
|
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
|
<div className="rb:font-medium rb:text-[#5B6167] rb:mb-2">{log.summary}</div>
|
|
<div className='rb:mt-2 rb:text-[#5B6167]'>
|
|
{typeof log.raw_results === 'string'
|
|
? <Markdown content={log.raw_results} />
|
|
: <>
|
|
{log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item: { statement: string; } , index: number) => (
|
|
<div key={index}>{item.statement}</div>
|
|
))}
|
|
{log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item: { content: string; }, index: number) => (
|
|
<div key={index}>{item.content}</div>
|
|
))}
|
|
</>
|
|
}
|
|
</div>
|
|
</ContentWrapper>
|
|
: log.type === 'perceptual_retrieve' && log.data && log.data?.length > 0
|
|
? <Flex gap={12} vertical>
|
|
{log.data.map((vo: any) => (
|
|
<ContentWrapper key={vo.id}>
|
|
<Flex vertical gap={16}>
|
|
{vo.file_path
|
|
? <>
|
|
{/(jpg|jpeg|png|gif|webp|svg)$/i.test(vo.file_type)
|
|
? <Image src={vo.file_path} alt={vo.file_name} width={432} className="rb:rounded-xl rb:h-45!" />
|
|
: /(mp4|webm|ogg|mov)$/i.test(vo.file_type)
|
|
? <VideoPlayer src={vo.file_path} />
|
|
: /(mp3|wav|ogg|m4a|aac)$/i.test(vo.file_type)
|
|
? <AudioPlayer src={vo.file_path} fileName={vo.file_name} 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 rb:wrap-break-word rb:line-clamp-1">{vo.file_name}</div>
|
|
<div className="rb:text-[#5B6167] rb:leading-4.5">
|
|
-
|
|
</div>
|
|
</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(vo.file_path)}
|
|
></div>
|
|
</Flex>
|
|
}
|
|
</>
|
|
: <div className="rb:bg-[#F6F6F6] rb:min-h-15.5! rb:rounded-xl rb:p-3!">
|
|
<Empty size={44} />
|
|
</div>
|
|
}
|
|
{['summary', 'keywords', 'topic', 'domain', 'scene', 'speaker_count', 'section_count'].map(key => {
|
|
const value = vo[key]
|
|
if (value) {
|
|
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:font-medium rb:leading-4.5">{vo}</div>)}
|
|
</Flex>
|
|
: '-'
|
|
}
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
})}
|
|
</Flex>
|
|
</ContentWrapper>
|
|
))}
|
|
</Flex>
|
|
: null
|
|
}
|
|
</ResultCard>
|
|
))}
|
|
</Flex>
|
|
}
|
|
</RbCard>
|
|
</Col>
|
|
</Row>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default MemoryConversation |