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,31 @@
import { Card } from 'antd'
import { type FC, type ReactNode } from 'react'
interface RbCardProps {
children: ReactNode;
title: string;
bodyClassName?: string;
style?: React.CSSProperties;
}
const RbCard: FC<RbCardProps> = ({ children, title, bodyClassName, style, ...props }) => {
return (
<Card
title={title}
classNames={{
header: "rb:min-h-[40px]! rb:p-[0_16px]! rb:rounded-[12px_12px_0_0]! rb:text-[14px]! rb:leading-[20px]! rb:font-medium! rb:border-b-[#DFE4ED]",
body: `rb:h-[calc(100%-40px)] rb:p-[16px]! ${bodyClassName || ''}`,
}}
style={{
borderRadius: '12px',
borderColor: '#DFE4ED',
background: '#FBFDFF',
height: 'calc(100vh - 152px)',
...style
}}
{...props}
>
{children}
</Card>
)
}
export default RbCard

View File

@@ -0,0 +1,82 @@
import { type FC, type ReactNode, useEffect, useRef, useState } from 'react'
import { Flex } from 'antd'
import clsx from 'clsx'
import ChatInput from './ChatInput'
import type { TestParams } from '../index'
import dayjs from 'dayjs'
import Markdown from '@/components/Markdown'
interface ChatProps {
empty?: ReactNode;
data: ChatItem[];
query?: TestParams;
onChange: (query: TestParams) => void;
onSend: () => void;
loading: boolean;
source?: 'conversation' | 'memory';
}
export interface ChatItem {
id?: string;
conversation_id?: string | null;
role?: 'user' | 'assistant';
content?: string;
message?: string;
created_at?: number | string;
meta_data?: Record<string, string | number>[];
}
const Chat: FC<ChatProps> = ({ empty, data, query, onChange, onSend, loading, source = 'memory' }) => {
const scrollContainerRefs = useRef<(HTMLDivElement | null)>(null)
const [isMemory, setIsMemory] = useState<boolean>(source === 'memory')
useEffect(() => {
setIsMemory(source === 'memory')
}, [source])
useEffect(() => {
setTimeout(() => {
if (scrollContainerRefs.current) {
scrollContainerRefs.current.scrollTop = scrollContainerRefs.current.scrollHeight;
}
}, 0);
}, [data])
return (
<div className="rb:h-full rb:relative rb:pt-[8px]">
{data.length === 0 ? (
<Flex vertical justify="space-between" className="rb:h-full rb:w-full rb:relative">
{/* Empty */}
<div className="rb:h-[calc(100%-144px)] rb:overflow-y-auto rb:overflow-x-hidden rb:flex rb:items-center rb:justify-center">
{empty}
</div>
<ChatInput source={source} query={query} onChange={onChange} onSend={onSend} loading={loading} />
</Flex>
)
: (
<div ref={scrollContainerRefs} className={clsx("rb:relative rb:overflow-y-auto", {
'rb:h-[calc(100%-152px)]': !isMemory,
'rb:h-[calc(100vh-362px)]': isMemory
})}>
{data.map((item, index) => (
<div key={index} className={clsx("rb:relative", {
'rb:mt-[24px]': index !== 0,
'rb:right-[0] rb:text-right': item.role === 'user',
'rb:left-[0] rb:text-left': item.role === 'assistant',
})}>
<div className={clsx('rb:border rb:text-left rb:rounded-[8px] rb:mt-[6px] rb:leading-[18px] rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-[400px]', {
'rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]': item.role === 'user',
'rb:bg-[#FFFFFF] rb:border-[#EBEBEB]': item.role === 'assistant',
})}>
<Markdown content={item.content || ''} />
</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-[16px] rb:font-regular">{dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}</div>
</div>
))}
</div>
)}
<ChatInput source={source} query={query} onChange={onChange} onSend={onSend} loading={loading} />
</div>
)
}
export default Chat

View File

@@ -0,0 +1,143 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Flex, Input, Form } from 'antd'
import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
import OnlineIcon from '@/assets/images/conversation/online.svg'
import DeepThinkingIcon from '@/assets/images/conversation/deepThinking.svg'
import SendIcon from '@/assets/images/conversation/send.svg'
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
import ButtonCheckbox from '@/components/ButtonCheckbox'
import DeepThinkingCheckedIcon from '@/assets/images/conversation/deepThinkingChecked.svg'
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
import LoadingIcon from '@/assets/images/conversation/loading.svg'
import type { TestParams } from '../index'
interface ChatInputProps {
query?: TestParams;
onChange: (query: TestParams) => void;
onSend: () => void;
loading: boolean;
source: 'conversation' | 'memory';
}
const searchSwitchList = [
{
icon: DeepThinkingIcon,
checkedIcon: DeepThinkingCheckedIcon,
value: '0',
label: 'deepThinking' // 深度思考
},
{
icon: MemoryFunctionIcon,
checkedIcon: MemoryFunctionCheckedIcon,
value: '1',
label: 'normalReply' // 普通回复
},
{
icon: OnlineIcon,
checkedIcon: OnlineCheckedIcon,
value: '2',
label: 'quickReply' // 快速回复
},
]
const ChatInput: FC<ChatInputProps> = ({ source,query, onChange, onSend, loading }) => {
const [form] = Form.useForm()
const { t } = useTranslation();
const values = Form.useWatch([], form);
const [search_switch, setSearchSwitch] = useState('0')
useEffect(() => {
if (onChange) {
onChange({...values, search_switch})
}
}, [values, search_switch, onChange])
useEffect(() => {
if (!query?.message) {
form.setFieldsValue({
message: undefined,
})
}
}, [form, query?.message])
useEffect(() => {
if (loading) {
form.setFieldsValue({
message: undefined,
})
}
}, [loading])
const handleChange = (value: string) => {
form.setFieldsValue({
search_switch: value,
})
setSearchSwitch(value)
}
return (
<Form form={form} layout="vertical" className="rb:absolute rb:bottom-[12px] rb:left-0 rb:right-0">
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-[12px] rb:min-h-[120px]">
<Form.Item name="message" className="rb:mb-[0]!">
<Input.TextArea
className="rb:m-[10px_12px_10px_12px]! rb:p-[0]! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto]"
// rows={4}
variant="borderless"
autoSize={{ minRows: 2, maxRows: 2 }}
onChange={(e) => onChange({ ...query, message: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && e.target.value?.trim() !== '' && !loading) {
e.preventDefault();
onSend();
}
}}
/>
</Form.Item>
<Flex align="center" justify="space-between" className="rb:m-[0_10px_10px_10px]!">
{source === 'memory' &&
<Flex gap={8}>
{searchSwitchList.map(item => (
<ButtonCheckbox
key={item.value}
icon={item.icon}
checkedIcon={item.checkedIcon}
checked={search_switch === item.value}
onChange={() => handleChange(item.value)}
>
{t(`memoryConversation.${item.label}`)}
</ButtonCheckbox>
))}
</Flex>
}
{source === 'conversation' &&
<Flex gap={8}>
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-[0]!">
<ButtonCheckbox
icon={OnlineIcon}
checkedIcon={OnlineCheckedIcon}
>
{t(`memoryConversation.web_search`)}
</ButtonCheckbox>
</Form.Item>
<Form.Item name="memory" valuePropName="checked" className="rb:mb-[0]!">
<ButtonCheckbox
icon={MemoryFunctionIcon}
checkedIcon={MemoryFunctionCheckedIcon}
>
{t(`memoryConversation.memory`)}
</ButtonCheckbox>
</Form.Item>
</Flex>
}
{loading ? <img src={LoadingIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />:
!values || !values?.message || values?.message?.trim() === '' ?
<img src={SendDisabledIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />
: <img src={SendIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" onClick={onSend} />
}
</Flex>
</Flex>
</Form>
)
}
export default ChatInput

View File

@@ -0,0 +1,241 @@
import { type FC, type ReactNode, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Col, Row, App, Skeleton, Space, Select } from 'antd'
import clsx from 'clsx'
import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg'
import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.svg'
import Card from './components/Card'
import Chat from './components/Chat'
import { readService, getUserMemoryList } from '@/api/memory'
import Empty from '@/components/Empty'
import Markdown from '@/components/Markdown'
import type { Data } from '@/views/UserMemory/types'
export interface TestParams {
group_id: string;
message: string;
search_switch: string;
history: { role: string; content: string }[];
web_search?: boolean;
memory?: boolean;
conversation_id?: string;
}
interface DataItem {
id: string;
question: string;
type: string;
reason: string;
}
export interface LogItem {
type: string;
title: string;
data?: DataItem[] | Record<string, string>;
raw_results?: string;
summary?: string;
query?: string;
reason?: string;
result?: string;
original_query: string;
index?: number;
}
const ContentWrapper: FC<{ children: ReactNode }> = ({ children }) => (
<div className="rb:p-[12px] rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-[8px]">
{children}
</div>
)
const MemoryConversation: FC = () => {
const { t } = useTranslation()
const { message } = App.useApp();
const [query, setQuery] = useState<TestParams>({
group_id: '',
message: '',
search_switch: '0',
history: [],
})
const [userId, setUserId] = useState<string>()
const [loading, setLoading] = useState<boolean>(false)
const [chatData, setChatData] = useState<{ content: string; created_at: string | number; role: string }[]>([])
const [logs, setLogs] = useState<LogItem[]>([])
const [userList, setUserList] = useState<Data[]>([])
useEffect(() => {
getUserMemoryList().then(res => {
setUserList((res as Data[] || []).map(item => ({
...item,
name: item.end_user?.other_name && item.end_user?.other_name !== '' ? item.end_user?.other_name : item.end_user?.id
})))
})
}, [])
const handleSend = () => {
if(!userId) {
message.warning(t('common.inputPlaceholder', { title: t('memoryConversation.userID') }))
return
}
setChatData(prev => [...prev, { content: query.message || '', created_at: new Date().getTime(), role: 'user' }])
setLoading(true)
readService({
...query,
group_id: userId,
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)
})
.finally(() => {
setLoading(false)
})
}
return (
<>
<Row gutter={16}>
<Col span={12}>
<Select
options={userList.map(item => ({
value: item.end_user?.id,
label: item?.name,
}))}
filterOption={(inputValue, option) => option.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1}
showSearch={true}
// filterOption={(inputValue, option) => option.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1}
placeholder={t('memoryConversation.searchPlaceholder')}
style={{ width: '100%', marginBottom: '16px' }}
onChange={setUserId}
/>
</Col>
</Row>
<Row gutter={16} className="rb:h-[calc(100vh-152px)] rb:overflow-hidden">
<Col span={12}>
<Card
title={t('memoryConversation.conversationContent')}
bodyClassName="rb:pb-[0]!"
>
<Chat
empty={
<Empty url={ConversationEmptyIcon} size={[140, 100]} title={t('memoryConversation.conversationContentEmpty')} />
}
data={chatData}
query={query}
onChange={setQuery}
onSend={handleSend}
loading={loading}
/>
</Card>
</Col>
<Col span={12}>
<Card
title={t('memoryConversation.memoryConversationAnalysis')}
bodyClassName='rb:overflow-auto'
>
{loading ?
<Skeleton active />
: !logs || logs.length === 0 ?
<Empty
url={AnalysisEmptyIcon}
className="rb:h-full"
/>
: <Space size={12} direction="vertical" style={{width: '100%'}}>
{logs.map((log, logIndex) => (
<div key={logIndex}
className={clsx(
`rb:p-[16px_24px] rb:rounded-[8px]`,
'rb:border-[1px] rb:border-[#DFE4ED]',
{
'rb:shadow-[inset_4px_0px_0px_0px_#155EEF]': logIndex % 3 === 0,
'rb:shadow-[inset_4px_0px_0px_0px_#369F21]': logIndex % 3 === 1,
'rb:shadow-[inset_4px_0px_0px_0px_#9C6FFF]': logIndex % 3 === 2,
}
)}
>
<div className="rb:text-[16px] rb:font-medium rb:leading-[22px] rb:mb-[24px]">{log.title}</div>
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
? <Space size={12} direction="vertical" style={{width: '100%'}}>
{log.data.map(vo => (
<ContentWrapper key={vo.id}>
<>
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
<div className="rb:mt-[8px] rb:text-[12px] rb:text-[#5B6167]">{vo.reason}</div>
</>
</ContentWrapper>
))}
</Space>
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
? <Space size={12} direction="vertical" style={{width: '100%'}}>
{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-[8px] rb:text-[#5B6167] rb:text-[12px]">{item}</div>
))}
</>
</ContentWrapper>
))}
</Space>
: log.type === 'search_result' && log.raw_results
? <ContentWrapper>
<div className="rb:font-medium rb:text-[#212332] rb:mb-[8px]">{log.query}</div>
<div className='rb:mt-[8px] rb:text-[12px] 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, index) => (
<div key={index}>{item.statement}</div>
))}
{log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item, index) => (
<div key={index}>{item.content}</div>
))}
</>
}
</div>
</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-[8px] rb:text-[12px] rb:text-[#5B6167]">{log.reason}</div>
<div className="rb:mt-[8px] rb:text-[12px] rb:text-[#5B6167]">{log.result}</div>
</ContentWrapper>
: log.type === 'output_type'
? <ContentWrapper>
<div className="rb:font-medium rb:text-[#212332] rb:mb-[8px]">{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-[8px]">{log.query}</div>
<div className="rb:font-medium rb:text-[12px] rb:text-[#5B6167] rb:mb-[8px]">{log.summary}</div>
<div className='rb:mt-[8px] rb:text-[12px] 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, index) => (
<div key={index}>{item.statement}</div>
))}
{log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item, index) => (
<div key={index}>{item.content}</div>
))}
</>
}
</div>
</ContentWrapper>
: null
}
</div>
))}
</Space>}
</Card>
</Col>
</Row>
</>
)
}
export default MemoryConversation