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,75 @@
import { useTranslation } from 'react-i18next';
import { type FC, useEffect, useState } from 'react';
import { Row, Col, Skeleton } from 'antd'
import CodeBlock from '@/components/Markdown/CodeBlock';
import { getMemoryApi } from '@/api/memory';
import RbCard from '@/components/RbCard/Card';
import type {
Data,
Section
} from './types';
import Empty from '@/components/Empty'
const ApiParameters: FC = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState<boolean>(false)
// const [data, setData] = useState<Data | null>(null)
const [apiData, setApiData] = useState<Section[]>([])
useEffect(() => {
getApiData()
}, [])
const getApiData = () => {
setLoading(true)
getMemoryApi().then((res) => {
const resp = res as Data || {}
// setData(resp)
setApiData(resp.sections || [])
})
.finally(() => setLoading(false))
}
return (
<div className="rb:pb-[24px]">
<h1 className="rb:text-2xl rb:font-semibold rb:mb-[8px]">{t('api.pageTitle')}</h1>
<p className="rb:text-[#5B6167] rb:text-[14px] rb:mb-[24px] rb:leading-[20px]">{t('api.pageSubTitle')}</p>
{loading
? <Skeleton />
: apiData.length === 0
? <Empty />
: <Row gutter={[24, 24]}>
{apiData.map((api, index) => (
<Col span={24} key={index}>
<RbCard
title={`${index + 1}. ${api.name}`}
>
<>
<div className="rb:mb-[24px] rb:bg-[#F0F3F8] rb:rounded-[8px] rb:shadow-[inset_4px_0px_0px_0px_#155EEF] rb:p-[16px_24px] rb:text-sm">
<span className="rb:bg-[#155EEF] rb:p-[2px_8px] rb:rounded-[6px] rb:text-[#fff] rb:mr-[16px]">{api.method}</span>
{api.path}
</div>
{api.desc &&<>
<div className="rb:text-base rb:font-medium rb:mb-[8px]">{t('api.desc')}</div>
<div className="rb:mb-[24px] rb:text-sm rb:text-[#5B6167]">{api.desc}</div>
</>}
{typeof api.input === 'string' && api.input !== '无' && <>
<div className="rb:text-base rb:font-medium rb:mb-[12px] rb:mt-[24px]">{t('api.input')}</div>
<CodeBlock value={api.input} />
</>}
{typeof api.output === 'string' && api.output !== '无' && <>
<div className="rb:text-base rb:font-medium rb:mb-[12px] rb:mt-[24px]">{t('api.output')}</div>
<CodeBlock value={api.output} />
</>}
</>
</RbCard>
</Col>
))}
</Row>
}
</div>
);
};
export default ApiParameters;

View File

@@ -0,0 +1,22 @@
export interface Section {
name: string;
path: string;
method: string;
input: string;
output: string;
desc: string;
}
export interface Data {
title: string;
meta: {
search_switch: {
value: string;
desc: string;
}[];
status_code: {
code: string;
desc: string;
}[];
}
sections: Section[]
}

View File

@@ -0,0 +1,446 @@
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom';
import { Row, Col, Space, Form, Input, Switch, Button, App, Spin } from 'antd'
import Chat from './components/Chat'
import RbCard from '@/components/RbCard/Card'
import Card from './components/Card'
import ModelConfigModal from './components/ModelConfigModal'
import type {
ModelConfigModalRef,
ChatData,
Config,
ModelConfig,
AgentRef,
KnowledgeBase,
KnowledgeConfig,
Variable,
MemoryConfig,
} from './types'
import type { Model } from '@/views/ModelManagement/types'
import { getModelList } from '@/api/models';
import { saveAgentConfig } from '@/api/application'
import Knowledge from './components/Knowledge'
import VariableList from './components/VariableList'
import { getApplicationConfig } from '@/api/application'
import { getKnowledgeBaseList } from '@/views/KnowledgeBase/service'
import { memoryConfigListUrl } from '@/api/memory'
import CustomSelect from '@/components/CustomSelect'
const DescWrapper: FC<{desc: string, className?: string}> = ({desc, className}) => {
return (
<div className={clsx(className, "rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] ")}>
{desc}
</div>
)
}
const LabelWrapper: FC<{title: string, className?: string; children?: ReactNode}> = ({title, className, children}) => {
return (
<div className={clsx(className, "rb:text-[14px] rb:font-medium rb:leading-[20px]")}>
{title}
{children}
</div>
)
}
const SwitchWrapper: FC<{ title: string, desc: string, name: string }> = ({ title, desc, name }) => {
const { t } = useTranslation();
return (
<div className="rb:flex rb:items-center rb:justify-between">
<LabelWrapper title={t(`application.${title}`)}>
<DescWrapper desc={t(`application.${desc}`)} className="rb:mt-[8px]" />
</LabelWrapper>
<Form.Item
name={name}
valuePropName="checked"
className="rb:mb-[0px]!"
>
<Switch />
</Form.Item>
</div>
)
}
const SelectWrapper: FC<{ title: string, desc: string, name: string, url: string }> = ({ title, desc, name, url }) => {
const { t } = useTranslation();
return (
<>
<LabelWrapper title={t(`application.${title}`)} className="rb:mb-[8px]">
</LabelWrapper>
<Form.Item
name={name}
className="rb:mb-[0px]!"
>
<CustomSelect
url={url}
hasAll={false}
valueKey='config_id'
labelKey="config_name"
/>
</Form.Item>
<DescWrapper desc={t(`application.${desc}`)} className="rb:mt-[8px]" />
</>
)
}
const Agent = forwardRef<AgentRef>((_props, ref) => {
const { t } = useTranslation()
const { id } = useParams();
const { message } = App.useApp()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Config | null>(null);
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
const [modelList, setModelList] = useState<Model[]>([])
const [defaultModel, setDefaultModel] = useState<Model | null>(null)
const [chatList, setChatList] = useState<ChatData[]>([])
const [formData, setFormData] = useState<{
default_model_config_id?: string,
model_parameters?: Config['model_parameters'],
} | null>(null)
const values = Form.useWatch<{
memoryEnabled: boolean;
memory_content?: string | number;
webSearch: boolean;
} & Config>([], form)
const [knowledgeConfig, setKnowledgeConfig] = useState<KnowledgeConfig>({ knowledge_bases: [] })
const [variableList, setVariableList] = useState<Variable[]>([])
const [isSave, setIsSave] = useState(false)
const initialized = useRef(false)
// 初始化完成标记
useEffect(() => {
if (data && values && formData) {
initialized.current = true
}
}, [data, values, formData])
useEffect(() => {
if (!initialized.current) return
if (isSave) return
setIsSave(true)
}, [knowledgeConfig])
useEffect(() => {
if (!initialized.current) return
if (isSave) return
setIsSave(true)
}, [variableList])
useEffect(() => {
if (!initialized.current) return
if (isSave) return
setIsSave(true)
}, [formData])
useEffect(() => {
if (!initialized.current) return
if (isSave) return
setIsSave(true)
}, [values])
useEffect(() => {
getModels()
getData()
}, [])
const getData = () => {
setLoading(true)
getApplicationConfig(id as string).then(res => {
const response = res as Config
setData(response)
const { memory, tools } = response
form.setFieldsValue({
...response,
memoryEnabled: memory?.enabled || false,
memory_content: memory?.memory_content ? Number(memory?.memory_content) : undefined,
webSearch: tools?.web_search?.enabled || false,
})
setFormData({
default_model_config_id: response.default_model_config_id,
model_parameters: response.model_parameters || {},
})
if (response?.knowledge_retrieval?.knowledge_bases?.length) {
getDefaultKnowledgeList(response)
}
}).finally(() => {
setLoading(false)
})
}
const getDefaultKnowledgeList = (data: Config) => {
if (!data || !data.knowledge_retrieval || !data.knowledge_retrieval?.knowledge_bases?.length) {
return
}
const initialList = [...(data?.knowledge_retrieval?.knowledge_bases || [])]
getKnowledgeBaseList(undefined, {
kb_ids: initialList.map(vo => vo.kb_id).join(','),
page: 1,
pagesize: 100,
})
.then(res => {
const list = res.items || []
const knowledge_bases: KnowledgeBase[] = list.map(item => {
const filterItem = initialList.find(vo => vo.kb_id === item.id)
return {
...item,
...filterItem
}
})
setData((prev) => {
prev = prev as Config
const knowledge_retrieval: KnowledgeConfig = {
...(prev?.knowledge_retrieval || {}),
knowledge_bases: [...knowledge_bases]
}
return {
...(prev || {}),
knowledge_retrieval
}
})
})
}
const refresh = (vo: ModelConfig, type: 'model' | 'chat') => {
if (type === 'model') {
const { default_model_config_id, ...rest } = vo
form.setFieldsValue({
default_model_config_id,
model_parameters: {...rest}
})
setFormData((prevState) => {
const prev = prevState as Config
return {
...(prev || {}),
default_model_config_id,
model_parameters: {...rest}
};
})
if (default_model_config_id === formData?.default_model_config_id) {
setChatList([{
label: vo.label || '',
model_config_id: default_model_config_id || '',
model_parameters: {...rest},
list: []
}])
}
} else if (type === 'chat') {
if (chatList.length >= 4) {
message.warning(t('application.maxChatCount'))
return
}
const { label, default_model_config_id, ...reset } = vo
setChatList((prev: ChatData[]) => {
const newChatItem: ChatData = {
label,
model_config_id: default_model_config_id || '',
model_parameters: {...reset},
list: []
};
return [
...(prev || []).map(item => ({
...item,
list: []
})),
newChatItem
];
})
}
}
const handleModelConfig = () => {
modelConfigModalRef.current?.handleOpen('model')
}
const handleClearDebugging = () => {
setChatList([])
}
// 保存Agent配置
const handleSave = (flag = true) => {
if (!isSave || !data) return Promise.resolve()
const { memoryEnabled, memory_content, webSearch, ...rest } = values
const { knowledge_bases = [], ...knowledgeRest } = knowledgeConfig || {}
// 从原数据中获取memory的其他必要属性
const originalMemory = data.memory || ({} as MemoryConfig)
const params: Config = {
...data,
...rest,
...(formData || {}),
memory: {
...originalMemory,
enabled: memoryEnabled,
memory_content: memory_content ? String(memory_content) : '',
max_history: originalMemory.max_history || '',
},
variables: variableList || [],
knowledge_retrieval: knowledge_bases.length > 0 ? {
...data.knowledge_retrieval,
...knowledgeRest,
knowledge_bases: knowledge_bases.map(item => ({
kb_id: item.id,
...(item.config || {})
}))
} as KnowledgeConfig : null,
tools: {
web_search: {
enabled: webSearch,
config: {
web_search: webSearch
}
}
}
}
return new Promise((resolve, reject) => {
saveAgentConfig(data.app_id, params)
.then(() => {
if (flag) {
message.success(t('common.saveSuccess'))
}
setIsSave(false)
resolve(true)
}).catch(error => {
reject(error)
})
})
}
const getModels = () => {
const requests = [getModelList({ type: 'llm', pagesize: 100, page: 1 }), getModelList({ type: 'chat', pagesize: 100, page: 1 })]
Promise.all(requests)
.then(responses => {
const [chatRes, modelRes] = responses as { items: Model[] }[]
const chatList = chatRes.items || []
const modelList = modelRes.items || []
setModelList([...chatList, ...modelList])
})
}
const handleAddModel = () => {
modelConfigModalRef.current?.handleOpen('chat')
}
useEffect(() => {
if (formData?.default_model_config_id && modelList.length > 0) {
const filterValue = modelList.find(item => item.id === formData.default_model_config_id)
setDefaultModel(filterValue as Model | null)
setChatList([{
label: filterValue?.name || '',
model_config_id: filterValue?.id || '',
model_parameters: {...(filterValue?.config || {})} as unknown as ModelConfig,
list: []
}])
}
}, [modelList, formData?.default_model_config_id])
useImperativeHandle(ref, () => ({
handleSave
}))
return (
<>
{loading && <Spin fullscreen></Spin>}
<Row className="rb:h-[calc(100vh-64px)]">
<Col span={12} className="rb:h-full rb:overflow-x-auto rb:border-r rb:border-[#DFE4ED] rb:p-[20px_16px_24px_16px]">
<div className="rb:flex rb:items-center rb:justify-end rb:mb-[20px]">
<Space size={10}>
<Button onClick={handleModelConfig} className="rb:group">
{defaultModel?.name ? <div className="rb:w-[16px] rb:h-[16px] rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null}
{defaultModel?.name || t('application.chooseModel')}
</Button>
<Button type="primary" onClick={() => handleSave()}>
{t('common.save')}
</Button>
</Space>
</div>
<Form form={form}>
<Space size={16} direction="vertical" style={{ width: '100%' }}>
{/* 提示词 */}
<Card title={t('application.promptConfiguration')}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-[11px]">
<div className="rb:font-medium rb:leading-[20px]">
{t('application.configuration')}
<span className="rb:font-regular rb:text-[12px] rb:text-[#5B6167]"> ({t('application.configurationDesc')})</span>
</div>
</div>
<Form.Item name="system_prompt" className="rb:mb-[0]!">
<Input.TextArea
placeholder={t('application.promptPlaceholder')}
styles={{
textarea: {
minHeight: '200px',
borderRadius: '8px'
},
}}
/>
</Form.Item>
</Card>
{/* 知识库 */}
<Knowledge
data={data?.knowledge_retrieval || { knowledge_bases: [] }}
onUpdate={setKnowledgeConfig}
/>
{/* 记忆配置 */}
<Card title={t('application.memoryConfiguration')}>
<Space size={24} direction='vertical' style={{ width: '100%' }}>
<SwitchWrapper title="dialogueHistoricalMemory" desc="dialogueHistoricalMemoryDesc" name="memoryEnabled" />
<SelectWrapper
title="selectMemoryContent"
desc="selectMemoryContentDesc"
name="memory_content"
url={memoryConfigListUrl}
/>
</Space>
</Card>
{/* 变量配置 */}
<VariableList
data={data?.variables}
onUpdate={setVariableList}
/>
{/* 工具配置 */}
<Card title={t('application.toolConfiguration')}>
<Space size={24} direction='vertical' style={{ width: '100%' }}>
<SwitchWrapper title="webSearch" desc="webSearchDesc" name="webSearch" />
{/* <SwitchWrapper title="codeExecutor" desc="codeExecutorDesc" name="codeExecutor" />
<SwitchWrapper title="imageGeneration" desc="imageGenerationDesc" name="imageGeneration" /> */}
</Space>
</Card>
</Space>
</Form>
</Col>
<Col span={12} className="rb:h-full rb:overflow-x-hidden rb:p-[20px_16px_24px_16px]">
<div className="rb:flex rb:items-center rb:justify-between rb:mb-[20px]">
{t('application.debuggingAndPreview')}
<Space size={10}>
<Button type="primary" ghost onClick={handleAddModel}>
+{t('application.addModel')}
</Button>
<div className="rb:w-[32px] rb:h-[32px] rb:cursor-pointer rb:bg-[url('@/assets/images/application/clean.svg')]" onClick={handleClearDebugging}></div>
</Space>
</div>
<RbCard height="calc(100vh - 160px)" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden">
<Chat
data={data as Config}
chatList={chatList}
updateChatList={setChatList}
handleSave={handleSave}
/>
</RbCard>
</Col>
</Row>
<ModelConfigModal
modelList={modelList}
data={formData as Config}
chatList={chatList}
ref={modelConfigModalRef}
refresh={refresh}
/>
</>
);
});
export default Agent;

View File

@@ -0,0 +1,154 @@
import { type FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Space, App
// Slider, Input,
// Form,
// Checkbox
} from 'antd';
import copy from 'copy-to-clipboard'
import Card from './components/Card';
// import qpsRestrictions from '@/assets/images/application/qpsRestrictions.svg'
// import dailyAdjustmentDosage from '@/assets/images/application/dailyAdjustmentDosage.svg'
// import tokenCap from '@/assets/images/application/tokenCap.svg'
// const limitList = [
// { key: 'qpsRestrictions', value: '10', icon: qpsRestrictions, unit: ' times/second' },
// { key: 'dailyAdjustmentDosage', value: '1000', icon: dailyAdjustmentDosage, unit: ' times/day' },
// { key: 'tokenCap', value: '10', icon: tokenCap, unit: 'M Tokens/day' },
// ]
// const sdkList = ['pythonSDK', 'nodejsSDK', 'goSDK', 'curlExample']
const Api: FC<{apiKeyList?: string[]}> = ({apiKeyList = []}) => {
const { t } = useTranslation();
const [activeMethods, setActiveMethod] = useState(['GET']);
const { message } = App.useApp()
// const [form] = Form.useForm();
const copyContent = window.location.origin + '/v1/chat'
const handleCopy = (content: string) => {
copy(content)
message.success(t('common.copySuccess'))
}
return (
<div className="rb:w-[1000px] rb:mt-[20px] rb:pb-[20px] rb:mx-auto">
{/* <Form form={form} layout="vertical"> */}
<Space size={20} direction="vertical" style={{width: '100%'}}>
<Card title={t('application.endpointConfiguration')}>
<div className="rb:p-[20px_20px_24px_20px] rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded-[8px]">
<Space size={8}>
{['GET', 'POST', 'PUT', 'DELETE'].map((method) => (
<Button key={method} type={activeMethods.includes(method) ? 'primary' : 'default'} onClick={() => setActiveMethod(prev => activeMethods.includes(method) ? prev.filter(m => m !== method) : [...prev, method])}>
{method}
</Button>
))}
</Space>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:mt-[20px] rb:p-[20px_16px] rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-[8px] rb:leading-[20px]">
{copyContent}
<Button className="rb:px-[8px]! rb:h-[28px]! rb:group" onClick={() => handleCopy(copyContent)}>
<div
className="rb:w-[16px] rb:h-[16px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/copy.svg')] rb:group-hover:bg-[url('@/assets/images/copy_active.svg')]"
></div>
{t('common.copy')}
</Button>
</div>
</div>
</Card>
<Card
title={t('application.authenticationMethod')}
// extra={
// <Button style={{padding: '0 8px', height: '24px'}} onClick={handleAdd}>+ {t('application.addApiKey')}</Button>
// }
>
<div className="rb:p-[10px_20px] rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded-[8px] rb:font-medium rb:text-center">
{t('application.apiKeyTitle')}
<p className="rb:mt-[6px] rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{t('application.apiKeyDesc')}</p>
</div>
{apiKeyList.map((item, index) => (
<div key={index} className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:mt-[20px] rb:p-[12px_16px] rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-[8px] rb:leading-[20px]">
{item}
<Space>
<Button className="rb:px-[8px]! rb:h-[28px]! rb:group" onClick={() => handleCopy(item)}>
<div
className="rb:w-[16px] rb:h-[16px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/copy.svg')] rb:group-hover:bg-[url('@/assets/images/copy_active.svg')]"
></div>
{t('common.copy')}
</Button>
{/* <div
className="rb:w-[24px] rb:h-[24px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
onClick={() => handleDelete(index)}
></div> */}
</Space>
</div>
))}
</Card>
{/* <Card title={t('application.requestResponseExample')}>
<div className="rb:mb-[12px] rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:font-regular">
{t('application.requestExample')}
<Button>{t('application.downloadPostmanCollection')}</Button>
</div>
<div className="rb:p-[16px_20px] rb:bg-[#F0F3F8] rb:rounded-[8px] rb:text-[#5B6167] rb:leading-[18px]">
curl -X POST https://api.example.com/v1/agent/execute \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d
</div>
<div className="rb:mb-[12px] rb:mt-[24px] rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:font-regular">
{t('application.responseExample')}
</div>
<div className="rb:p-[16px_20px] rb:bg-[#F0F3F8] rb:rounded-[8px] rb:text-[#5B6167] rb:leading-[18px]">
curl -X POST https://api.example.com/v1/agent/execute \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d
</div>
</Card>
<Card title={t('application.rateLimitingStrategy')}>
<div className="rb:grid rb:grid-cols-3 rb:gap-[18px]">
{limitList.map(item => (
<div key={item.key} className="rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded-[8px] rb:p-[16px_20px]">
<div className="rb:flex rb:justify-between">
<div className="rb:leading-[20px]">
{t(`application.${item.key}`)}
<div className="rb:text-[14px] rb:font-medium rb:text-[#155EEF] rb:mt-[8px]">{item.value}{item.unit}</div>
</div>
<img src={item.icon} className="rb:w-[24px] rb:h-[24px]" />
</div>
<Slider style={{ margin: '24px 0 0 0' }} value={item.value} />
</div>
))}
</div>
</Card>
<Card title={t('application.sdkDownload')}>
<div className="rb:grid rb:grid-cols-4 rb:gap-[16px]">
{sdkList.map(item => (
<div key={item} className="rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded-[8px] rb:p-[24px_20px] rb:text-center">
{t(`application.${item}`)}
</div>
))}
</div>
</Card>
<Card title={t('application.advancedSettings')}>
<Form.Item
name="WebhookReturnsTimeout"
label={<>{t('application.WebhookReturnsTimeout')}<span className="rb:text-[#5B6167] rb:text-[12px] rb:font-regular"> ({t('application.WebhookReturnsTimeoutDesc')})</span></>}
>
<Input disabled />
</Form.Item>
<Form.Item
name="whitelistIP"
label={<>{t('application.whitelistIP')}<span className="rb:text-[#5B6167] rb:text-[12px] rb:font-regular"> ({t('application.whitelistIPDesc')})</span></>}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
name="whitelistIP"
className="rb:mb-[0]!"
>
<Checkbox>{t('application.publicAPIDocumentation')}</Checkbox>
</Form.Item>
</Card> */}
</Space>
{/* </Form> */}
</div>
);
}
export default Api;

View File

@@ -0,0 +1,205 @@
import { type FC, useEffect, useState, useRef, type Key } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom';
import Card from './components/Card'
import { Form, Space, Row, Col, Button, Flex, App } from 'antd'
import type { DefaultOptionType } from 'antd/es/select'
import Tag, { type TagProps } from './components/Tag'
import CustomSelect from '@/components/CustomSelect';
import { getApplicationListUrl, getMultiAgentConfig, saveMultiAgentConfig } from '@/api/application';
import type {
Config,
SubAgentModalRef,
ChatData,
SubAgentItem
} from './types'
import Chat from './components/Chat'
import RbCard from '@/components/RbCard/Card'
import SubAgentModal from './components/SubAgentModal'
import Empty from '@/components/Empty'
const tagColors = ['processing', 'warning', 'default']
const MAX_LENGTH = 5;
const Cluster: FC<{application: SubAgentItem}> = ({application}) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm()
const { id } = useParams()
const subAgentModalRef = useRef<SubAgentModalRef>(null)
const [data, setData] = useState<Config | null>(null)
const values = Form.useWatch([], form)
const [subAgents, setSubAgents] = useState<SubAgentItem[]>([])
const [chatList, setChatList] = useState<ChatData[]>([
{
list: []
},
])
const handleSave = (flag = true) => {
const params = {
...values,
sub_agents: (subAgents || []).map(item => ({
...item,
priority: 1,
}))
}
console.log('params', params)
form.validateFields().then(() => {
saveMultiAgentConfig(id as string, params).then(() => {
if (flag) {
message.success(t('common.saveSuccess'))
}
})
})
}
useEffect(() => {
getData()
}, [id])
useEffect(() => {
if (application) {
form.setFieldsValue({
name: application.name,
})
}
}, [application])
const getData = () => {
if (!id) {
return
}
getMultiAgentConfig(id as string).then(res => {
const response = res as Config
setData(response)
form.setFieldsValue({
...response,
})
setSubAgents(response.sub_agents || [])
})
}
const handleSubAgentModal = (agent?: SubAgentItem) => {
subAgentModalRef.current?.handleOpen(agent)
}
const refreshSubAgents = (agent: SubAgentItem) => {
// setSubAgents(subAgents)
const index = subAgents.findIndex(item => item.agent_id === agent.agent_id)
const newSubAgents = [...subAgents]
if (index === -1) {
if (subAgents.length >= MAX_LENGTH) {
message.warning(t('application.subAgentMaxLength', {maxLength: MAX_LENGTH}))
return
}
setSubAgents([...newSubAgents, agent])
} else {
newSubAgents[index] = agent
setSubAgents(newSubAgents)
}
}
const handleDeleteSubAgent = (agent: SubAgentItem) => {
setSubAgents(prev => prev.filter(item => item.agent_id !== agent.agent_id))
}
const handleChange = (value: Key, option?: DefaultOptionType | DefaultOptionType[] | undefined) => {
if (option && !Array.isArray(option)) {
form.setFieldsValue({ master_agent_name: option.children })
}
}
return (
<Row className="rb:h-[calc(100vh-64px)]">
<Col span={12} className="rb:h-full rb:overflow-x-auto rb:border-r rb:border-[#DFE4ED] rb:p-[20px_16px_24px_16px]">
<div className="rb:flex rb:items-center rb:justify-end rb:mb-[20px]">
<Button type="primary" onClick={() => handleSave()}>
{t('common.save')}
</Button>
</div>
<Form form={form} layout="vertical">
<Space size={20} direction="vertical" style={{width: '100%'}}>
<Card title={t('application.supervisorAgent')}>
<Row gutter={18}>
<Col span={24}>
<Form.Item
name="master_agent_id"
label={
<div className="rb:font-medium">
{t('application.agentName')}
</div>
}
className="rb:mb-[20px]!"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getApplicationListUrl}
params={{ pagesize: 100, status: 'active', type: 'agent' }}
valueKey="id"
labelKey="name"
hasAll={false}
optionFilterProp="search"
showSearch={true}
onChange={handleChange}
/>
</Form.Item>
<Form.Item name="master_agent_name" hidden />
</Col>
</Row>
</Card>
<Card title={t('application.subAgentsManagement')}>
<Flex align="center" justify="space-between">
<div className="rb:font-regular rb:text-[#5B6167] rb:leading-[20px]">{t('application.added')}: {subAgents.length}/{MAX_LENGTH}</div>
<Button size="small" disabled={subAgents.length >= MAX_LENGTH} onClick={() => handleSubAgentModal()}>{t('application.addSubAgent')}</Button>
</Flex>
{subAgents.length === 0
? <Empty size={88} />
: subAgents.map((agent, index) => (
<Flex key={index} align="center" justify="space-between"
className="rb:mt-[16px]! rb:w-full! rb:border rb:border-[#DFE4ED] rb:rounded-[8px] rb:p-[20px_31px_20px_20px]!"
>
<Flex className="rb:w-[calc(100%-80px)]!">
<div className="rb:w-[48px] rb:h-[48px] rb:rounded-[8px] rb:mr-[13px] rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{agent.name?.[0]}
</div>
<div className="rb:flex rb:flex-col rb:justify-center rb:max-w-[calc(100%-60px)]">
{agent.name}
{agent.role && <div className="rb:font-regular rb:leading-[20px] rb:text-[#5B6167] rb:mt-[6px]">{agent.role || '-'}</div>}
{agent.capabilities && <Flex wrap gap={8} className="rb:mt-[16px]">{agent.capabilities.map((tag, tagIndex) => <Tag key={tagIndex} color={tagColors[tagIndex % tagColors.length] as TagProps['color']}>{tag}</Tag>)}</Flex>}
</div>
</Flex>
<Space>
<div
className="rb:w-[32px] rb:h-[32px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleSubAgentModal(agent)}
></div>
<div
className="rb:w-[32px] rb:h-[32px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteSubAgent(agent)}
></div>
</Space>
</Flex>
))}
</Card>
</Space>
</Form>
</Col>
<Col span={12} className="rb:h-full rb:overflow-x-hidden rb:p-[20px_16px_24px_16px]">
<RbCard height="100%" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden">
<Chat
data={data as Config}
chatList={chatList}
updateChatList={setChatList}
handleSave={handleSave}
source="cluster"
/>
</RbCard>
</Col>
<SubAgentModal
ref={subAgentModalRef}
refresh={refreshSubAgents}
/>
</Row>
)
}
export default Cluster

View File

@@ -0,0 +1,159 @@
import { type FC, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { Button, Space, Input, Form, App } from 'antd';
import Tag, { type TagProps } from './components/Tag'
import RbCard from '@/components/RbCard/Card'
import { getReleaseList, rollbackRelease } from '@/api/application'
import ReleaseModal from './components/ReleaseModal'
import ReleaseShareModal from './components/ReleaseShareModal'
import type { Release, ReleaseModalRef, ReleaseShareModalRef } from './types'
import type { Application } from '@/views/ApplicationManagement/types'
import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format';
import Markdown from '@/components/Markdown'
const tagColors: Record<Release['tagKey'], TagProps['color']> = {
current: 'processing',
rolledBack: 'warning',
history: 'default',
}
const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refresh}) => {
const { t } = useTranslation();
const { message } = App.useApp()
const releaseModalRef = useRef<ReleaseModalRef>(null)
const releaseShareModalRef = useRef<ReleaseShareModalRef>(null)
const [selectedVersion, setSelectedVersion] = useState<Release | null>(null);
const [releaseList, setReleaseList] = useState<Release[]>([])
useEffect(() => {
getData()
}, [data.id])
const getData = () => {
refresh()
getReleaseList(data.id).then(res => {
const response = res as Release[] || []
setReleaseList(response)
setSelectedVersion(response?.[0])
})
}
const handleRollback = () => {
if (!selectedVersion) return
rollbackRelease(data.id, selectedVersion.version).then(() => {
getData()
message.success(t('common.operateSuccess'))
})
}
return (
<div className="rb:flex rb:h-[calc(100vh-64px)]">
<div className="rb:h-full rb:overflow-y-auto rb:w-[432px] rb:flex-[0_0_auto] rb:border-r-[1px] rb:border-[#DFE4ED] rb:p-[16px]">
<Space size={16} direction="vertical" style={{ width: '100%' }}>
<div className="rb:leading-[22px] rb:px-[4px]">
{t('application.versionList')}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[4px] rb:leading-[16px]">{t('application.versionListDesc')}</div>
</div>
{releaseList.length === 0
? <Empty />
: selectedVersion && releaseList.map((version, index) => {
const tagKey = version.id === data.current_release_id && index === 0
? 'current'
: version.id === data.current_release_id
? 'rolledBack' : 'history'
return (
<RbCard
key={version.version}
title={<>
{version.version_name || `v${version.version}`}
{tagKey && <Tag color={tagColors[tagKey]} className="rb:ml-[8px]">
{tagKey}
</Tag>}
</>}
className={clsx("rb:hover:border-[#155EEF]! rb:cursor-pointer", {
'rb:bg-[rgba(21,94,239,0.06)]! rb:border-[#155EEF]!': version.id === selectedVersion.id,
'rb:border-[#DFE4ED] rb:bg-[#FBFDFF]': version.id !== selectedVersion.id
})}
headerType="borderless"
onClick={() => setSelectedVersion(version)}
>
<div className="rb:leading-[20px] rb:line-clamp-2 rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
<Markdown content={version.release_notes} />
</div>
<div className="rb:mt-[16px] rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px]">
{t('application.publishedOn')} {formatDateTime(version.published_at, 'YYYY-MM-DD HH:mm:ss')}
</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-[4px] rb:leading-[16px]">
{t('application.publisher')}: {version.publisher_name}
</div>
</RbCard>
)
})
}
</Space>
</div>
<div className="rb:h-full rb:overflow-y-auto rb:flex-[1_1_auto] rb:p-[16px]">
<Form layout="vertical">
<div className={clsx("rb:leading-[22px] rb:px-[4px] rb:flex rb:items-center rb:text-[16px] rb:font-medium rb:mb-[21px]", {
'rb:justify-between': selectedVersion,
'rb:justify-end': !selectedVersion
})}>
{selectedVersion && t('application.DetailsOfVersion', { version: selectedVersion.version_name || `v${selectedVersion.version}` || '-' })}
<Space size={10}>
{selectedVersion && <>
{/* <Button>{t('application.exportDSLFile')}</Button> */}
{data.current_release_id !== selectedVersion.id && <Button onClick={handleRollback}>{t('application.willRollToThisVersion')}</Button>}
<Button type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</Button>
</>}
<Button type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</Button>
</Space>
</div>
{selectedVersion &&
<Space size={16} direction="vertical" style={{ width: '100%' }}>
<RbCard title={t('application.VersionInformation')} headerType="borderless">
<div className="rb:grid rb:grid-cols-3 rb:gap-[16px]">
<Form.Item label={t('application.releaseTime')} className="rb:mb-[0]!">
<Input value={formatDateTime(selectedVersion.published_at, 'YYYY-MM-DD HH:mm:ss')} disabled />
</Form.Item>
<Form.Item label={t('application.lastUpdateTime')} className="rb:mb-[0]!">
<Input value={formatDateTime(selectedVersion.updated_at, 'YYYY-MM-DD HH:mm:ss')} disabled />
</Form.Item>
<Form.Item label={t('application.editor')} className="rb:mb-[0]!">
<Input value={selectedVersion.publisher_name} disabled />
</Form.Item>
</div>
</RbCard>
{/* 日志 */}
<RbCard title={t('application.changeLog')} headerType="borderless">
<Space size={16} direction="vertical" style={{ width: '100%' }}>
{selectedVersion && (
<RbCard
headerType="borderBL"
title={<div className="rb:text-[14px]">{formatDateTime(selectedVersion.published_at, 'YYYY-MM-DD HH:mm:ss')}</div>}
extra={<span className="rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px]">{selectedVersion.publisher_name}</span>}
>
<div className="rb:leading-[20px] rb:font-medium rb:font-regular rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px]">
<Markdown content={selectedVersion.release_notes} />
</div>
</RbCard>
)}
</Space>
</RbCard>
</Space>
}
</Form>
</div>
<ReleaseModal
data={data}
ref={releaseModalRef}
refreshTable={getData}
/>
<ReleaseShareModal
ref={releaseShareModalRef}
version={selectedVersion}
/>
</div>
);
}
export default ReleasePage;

View File

@@ -0,0 +1,81 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Row, Col, Space, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import type { AiPromptModalRef } from '../types'
// import { request } from '@/utils/request'
import RbModal from '@/components/RbModal'
import Markdown from '@/components/Markdown';
interface AiPromptModalProps {
refresh: () => void;
}
const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
// refresh
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [content, setContent] = useState('');
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setLoading(false)
};
const handleOpen = () => {
setVisible(true);
};
// 封装保存方法,添加提交逻辑
// const handleSave = () => {
// }
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={t('application.AIPromptAssistant')}
open={visible}
onCancel={handleClose}
footer={null}
width={1000}
>
<Row className="rb:rounded-[12px] rb:border rb:border-[#DFE4ED]">
<Col span={12} className="rb:border-r rb:border-[#DFE4ED]">
<div className="rb:p-[12px_17px] rb:border-b rb:border-[#DFE4ED]">{t('application.generatedPrompt')}</div>
<div className="rb:h-[200px] rb:p-[16px]">
<div className="rb:bg-[#F0F3F8] rb:h-full rb:w-full">
<Markdown
content={content}
/>
</div>
</div>
</Col>
<Col span={12}>
<div className="rb:p-[12px_17px] rb:border-b rb:border-[#DFE4ED]">{t('application.conversationOptimizationPrompt')}</div>
<div className="rb:h-[200px] rb:p-[16px]">
<div className="rb:bg-[#F0F3F8] rb:h-full rb:w-full">
</div>
</div>
</Col>
<Col span={12} className="rb:border-r rb:border-[#DFE4ED]">
<Space>
<Button>{t('common.copy')}</Button>
<Button type="primary">{t('common.apply')}</Button>
</Space>
</Col>
<Col span={12}>
</Col>
</Row>
</RbModal>
);
});
export default AiPromptModal;

View File

@@ -0,0 +1,96 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ApiExtensionModalData, ApiExtensionModalRef } from '../types'
import RbModal from '@/components/RbModal'
const FormItem = Form.Item;
interface ApiExtensionModalProps {
refresh: () => void;
}
const ApiExtensionModal = forwardRef<ApiExtensionModalRef, ApiExtensionModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ApiExtensionModalData>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
form.resetFields();
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
console.log('values', values)
refresh()
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('application.addApiExtension')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="name"
label={t('application.name')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="apiEndpoint"
label={t('application.apiEndpoint')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="apiKey"
label={t('application.apiKey')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
</Form>
</RbModal>
);
});
export default ApiExtensionModal;

View File

@@ -0,0 +1,27 @@
import { type FC, type ReactNode } from 'react'
import RbCard from '@/components/RbCard/Card'
interface CardProps {
title?: string | ReactNode;
children: ReactNode;
extra?: ReactNode;
}
const Card: FC<CardProps> = ({
title,
children,
extra,
}) => {
return (
<RbCard
title={title}
extra={extra}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]! rb:before:h-[19px]"
>
{children}
</RbCard>
)
}
export default Card

View File

@@ -0,0 +1,337 @@
import { type FC, useRef, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx'
import { Input, Form } from 'antd'
import ChatIcon from '@/assets/images/application/chat.svg'
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.svg'
import type { ChatItem, ChatData, Config } from '../types'
import { runCompare, draftRun } from '@/api/application'
import Empty from '@/components/Empty'
import Markdown from '@/components/Markdown'
interface ChatProps {
chatList: ChatData[];
data: Config;
updateChatList: (list: ChatData[]) => void;
handleSave: (flag?: boolean) => Promise<any>;
source?: 'cluster' | 'agent';
}
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent' }) => {
const { t } = useTranslation();
const [form] = Form.useForm<{ message: string }>()
const scrollContainerRefs = useRef<(HTMLDivElement | null)[]>([])
const [loading, setLoading] = useState(false)
const [isCluster, setIsCluster] = useState(source === 'cluster')
const [conversationId, setConversationId] = useState<string | null>(null)
const [compareLoading, setCompareLoading] = useState(false)
// 当聊天列表更新时,自动滚动到底部
useEffect(() => {
// 延迟一下确保DOM已经更新
setTimeout(() => {
scrollContainerRefs.current.forEach(container => {
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}, 0);
}, [chatList]);
useEffect(() => {
setIsCluster(source === 'cluster')
}, [source])
const handleSend = () => {
if (loading) return
setLoading(true)
setCompareLoading(true)
handleSave(false)
.then(() => {
const message = form.getFieldValue('message')
if (!message || message.trim() === '') return
const newUserMessage: ChatItem = {
role: 'question',
content: message,
time: Date.now(),
};
updateChatList((prev: ChatData[]) => {
return prev.map(item => ({
...item,
list: [
...(item.list || []),
newUserMessage
]
}))
})
form.setFieldsValue({ message: undefined })
// 添加空的助手消息用于流式更新
const assistantMessages: Record<string, ChatItem> = {};
if (isCluster) {
const assistantMessage: ChatItem = {
role: 'answer',
content: '',
time: Date.now(),
};
assistantMessages['cluster'] = assistantMessage;
updateChatList((prev: ChatData[]) => prev.map(item => ({
...item,
list: [...(item.list || []), assistantMessage]
})))
} else {
chatList.forEach(item => {
const assistantMessage: ChatItem = {
role: 'answer',
content: '',
time: Date.now(),
};
assistantMessages[item.model_config_id] = assistantMessage;
});
updateChatList((prev: ChatData[]) => prev.map(item => ({
...item,
list: [...(item.list || []), assistantMessages[item.model_config_id]]
})))
}
const handleStreamMessage = (data: string) => {
setCompareLoading(false)
try {
const lines = data.split('\n');
let currentEvent = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('event:')) {
currentEvent = line.substring(6).trim();
} else if (line.startsWith('data:') && (!isCluster && currentEvent === 'model_message')) {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
if (parsed.content && parsed.model_config_id) {
const targetIndex = chatList.findIndex(item => item.model_config_id === parsed.model_config_id);
if (targetIndex !== -1) {
updateChatList((prev: ChatData[]) => prev.map((item, index) => {
if (index === targetIndex) {
return {
...item,
list: item.list?.map((msg, msgIndex) => {
if (msgIndex === item.list!.length - 1 && msg.role === 'answer') {
return { ...msg, content: msg.content + parsed.content };
}
return msg;
}) || []
};
}
return item;
}))
}
}
if (parsed.conversation_id) {
setConversationId(parsed.conversation_id);
}
} else if (line.startsWith('data:') && (isCluster && currentEvent === 'message')) {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
if (parsed.content) {
updateChatList((prev: ChatData[]) => prev.map((item, index) => {
if (index === 0) {
return {
...item,
list: item.list?.map((msg, msgIndex) => {
if (msgIndex === item.list!.length - 1 && msg.role === 'answer') {
return { ...msg, content: (msg.content || '') + parsed.content };
}
return msg;
}) || []
};
}
return item;
}))
}
if (parsed.conversation_id) {
setConversationId(parsed.conversation_id);
}
} else if (line.startsWith('data:') && (!isCluster && currentEvent === 'model_end')) {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
if (parsed.message_length === 0 && parsed.model_config_id) {
const targetIndex = chatList.findIndex(item => item.model_config_id === parsed.model_config_id);
if (targetIndex !== -1) {
updateChatList((prev: ChatData[]) => prev.map((item, index) => {
if (index === targetIndex) {
return {
...item,
list: item.list?.map((msg, msgIndex) => {
if (msgIndex === item.list!.length - 1 && msg.role === 'answer') {
return { ...msg, content: null };
}
return msg;
}) || []
};
}
return item;
}))
}
}
if (parsed.conversation_id) {
setConversationId(parsed.conversation_id);
}
} else if (line.startsWith('data:') && (isCluster && currentEvent === 'model_end')) {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
if (parsed.message_length === 0) {
updateChatList((prev: ChatData[]) => prev.map((item, index) => {
if (index === 0) {
return {
...item,
list: item.list?.map((msg, msgIndex) => {
if (msgIndex === item.list!.length - 1 && msg.role === 'answer') {
return { ...msg, content: null };
}
return msg;
}) || []
};
}
return item;
}))
}
if (parsed.conversation_id) {
setConversationId(parsed.conversation_id);
}
} else if (currentEvent === 'compare_end') {
setLoading(false);
}
}
} catch (e) {
console.error('Parse stream data error:', e);
}
};
setTimeout(() => {
if (isCluster) {
draftRun(data.app_id, { message, conversation_id: conversationId, stream: true }, handleStreamMessage)
.finally(() => setLoading(false))
} else {
runCompare(data.app_id, {
message,
models: chatList.map(item => ({
model_config_id: item.model_config_id,
label: item.label,
model_parameters: item.model_parameters,
conversation_id: item.conversation_id
})),
variables: {},
"parallel": true,
"stream": true,
"timeout": 60,
}, handleStreamMessage)
.finally(() => setLoading(false));
}
}, 0)
})
.catch(() => {
setLoading(false)
setCompareLoading(false)
})
}
const handleDelete = (index: number) => {
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
}
return (
<div className="rb:relative rb:h-[calc(100vh-110px)]">
{chatList.length === 0
? <Empty
url={DebuggingEmpty}
title={t('application.debuggingEmpty')}
subTitle={t('application.debuggingEmptyDesc')}
className="rb:h-full"
/>
: <>
<div className={clsx(`rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full`, {
'rb:h-[calc(100vh-236px)]': !isCluster,
'rb:h-[calc(100%-76px)]': isCluster,
})}>
{chatList.map((chat, index) => (
<div key={index} className={clsx('rb:h-full rb:flex rb:flex-col', {
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
})}>
{chat.label &&
<div className={clsx(
"rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]",
{
'rb:rounded-tr-[12px]': index === chatList.length - 1,
'rb:rounded-tl-[12px]': index === 0,
}
)}>
<div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'>
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
<div
className="rb:w-[16px] rb:h-[16px] rb:cursor-pointer rb:absolute rb:top-[12px] rb:right-[12px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
onClick={() => handleDelete(index)}
></div>
</div>
</div>
}
{!chat.list || chat.list.length === 0
? <Empty url={ChatIcon} title={t('application.chatEmpty')} className="rb:h-full" />
: (
<div ref={el => scrollContainerRefs.current[index] = el} className={clsx(`rb:relative rb:overflow-y-auto rb:overflow-x-hidden`, {
'rb:h-[calc(100vh-186px)]': isCluster,
'rb:h-[calc(100vh-286px)]': !isCluster,
})}>
{chat.list?.map((vo, voIndex) => {
if (compareLoading && voIndex === chat.list?.length - 1) {
return null
}
return (
<div key={voIndex} className={clsx("rb:relative rb:mt-[24px]", {
'rb:right-[16px] rb:text-right': vo.role === 'question',
'rb:left-[16px] rb:text-left': vo.role !== 'question',
})}>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-[16px] rb:font-regular">{vo.role === 'question' ? 'You' : chat.label}</div>
<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:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': vo.role !== 'question' && vo.content === null,
'rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]': vo.role === 'question' && vo.content,
'rb:bg-[#ffffff] rb:border-[rgba(235,235,235,1)]': vo.role !== 'question' && (vo.content || vo.content === ''),
'rb:max-w-[400px]': chatList.length === 1,
'rb:max-w-[260px]': chatList.length === 2,
'rb:max-w-[150px]': chatList.length === 3,
'rb:max-w-[108px]': chatList.length === 4,
})}>
<Markdown content={vo.content === null ? t('application.ReplyException') : vo.content} />
</div>
</div>
)
})}
</div>
)
}
</div>
))}
</div>
<div className="rb:flex rb:items-center rb:gap-[10px] rb:p-[16px]">
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
<Form.Item name="message" className="rb:mb-[0]!">
<Input
className="rb:h-[44px] rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
placeholder={t('application.chatPlaceholder')}
onPressEnter={handleSend}
/>
</Form.Item>
</Form>
<img src={ChatSendIcon} className={clsx("rb:w-[44px] rb:h-[44px] rb:cursor-pointer", {
'rb:opacity-50': loading,
})} onClick={handleSend} />
</div>
</>
}
</div>
)
}
export default Chat;

View File

@@ -0,0 +1,131 @@
import { type FC, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Layout, Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { useTranslation } from 'react-i18next';
import styles from '../index.module.css'
import logoutIcon from '@/assets/images/logout.svg'
import editIcon from '@/assets/images/edit_hover.svg'
import copyIcon from '@/assets/images/copy_hover.svg'
import exportIcon from '@/assets/images/export_hover.svg'
import deleteIcon from '@/assets/images/delete_hover.svg'
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
import type { CopyModalRef } from '../types'
import { deleteApplication } from '@/api/application'
import CopyModal from './CopyModal'
const { Header } = Layout;
const tabKeys = ['arrangement', 'api', 'release']
const menuIcons: Record<string, string> = {
edit: editIcon,
copy: copyIcon,
export: exportIcon,
delete: deleteIcon
}
interface ConfigHeaderProps {
application?: Application;
activeTab: string;
handleChangeTab: (key: string) => void;
refresh: () => void;
}
const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleChangeTab, refresh }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams();
const applicationModalRef = useRef<ApplicationModalRef>(null);
const copyModalRef = useRef<CopyModalRef>(null);
const formatTabItems = () => {
return tabKeys.map(key => ({
key,
label: t(`application.${key}`),
}))
}
const formatMenuItems = () => {
const items = ['edit', 'copy', 'delete'].map(key => ({
key,
icon: <img src={menuIcons[key]} className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]" />,
label: t(`common.${key}`),
}))
return {
items,
onClick: handleClick
}
}
const handleClick: MenuProps['onClick'] = ({ key }) => {
console.log('key', key)
switch (key) {
case 'edit':
applicationModalRef.current?.handleOpen(application as Application)
break;
case 'copy':
copyModalRef.current?.handleOpen()
break;
case 'export':
break;
case 'delete':
handleDelete()
break;
}
}
const handleDelete = () => {
if (!id) {
return
}
deleteApplication(id as string)
.then(() => {
goToApplication()
})
.catch(() => {
console.error('Failed to delete application');
});
}
const goToApplication = () => {
navigate('/application', { replace: true })
}
return (
<>
<Header className="rb:w-full rb:h-[64px] rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-[32px]">
<div className="rb:h-[32px] rb:flex rb:items-center rb:font-medium">
<div className="rb:w-[32px] rb:h-[32px] rb:rounded-[8px] rb:mr-[13px] rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[24px] rb:text-[#ffffff]">
{application?.name[0]}
</div>
<div className="rb:max-w-[100%-80px] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{application?.name}</div>
<Dropdown
menu={formatMenuItems()}
trigger={['click']}
placement="bottomRight"
>
<div
className="rb:w-[20px] rb:h-[20px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
></div>
</Dropdown>
</div>
<div className="rb:flex rb:justify-center">
<Tabs
activeKey={activeTab}
items={formatTabItems()}
onChange={handleChangeTab}
className={styles.tabs}
/>
</div>
<div className="rb:h-[32px] rb:flex rb:items-center rb:justify-end rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goToApplication}>
<img src={logoutIcon} className="rb:mr-[8px]" />
{t('application.returnToApplicationList')}
</div>
</Header>
<ApplicationModal
ref={applicationModalRef}
refresh={refresh}
/>
<CopyModal ref={copyModalRef} data={application as Application} />
</>
);
};
export default ConfigHeader;

View File

@@ -0,0 +1,87 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { CopyModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { copyApplication } from '@/api/application'
import type { Application } from '@/views/ApplicationManagement/types'
const FormItem = Form.Item;
interface CopyModalProps {
data: Application
}
const CopyModal = forwardRef<CopyModalRef, CopyModalProps>(({
data
}, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
setVisible(false);
setLoading(true)
const values = form.getFieldsValue()
copyApplication(data.id, values.new_name)
.then((res) => {
const resData = res as Application
navigate(`/application/config/${resData.id}`)
})
.finally(() => {
setLoading(false)
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<>
<RbModal
title={t('application.copyApplication')}
open={visible}
onCancel={handleClose}
okText={t('common.copy')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
{/* 应用名 */}
<FormItem
name="new_name"
label={t('application.applicationName')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
</Form>
</RbModal>
</>
);
});
export default CopyModal;

View File

@@ -0,0 +1,160 @@
import { type FC, useRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, List } from 'antd'
import knowledgeEmpty from '@/assets/images/application/knowledgeEmpty.svg'
import Card from './Card'
import type {
KnowledgeConfigForm,
KnowledgeConfig,
RerankerConfig,
KnowledgeBase,
KnowledgeModalRef,
KnowledgeConfigModalRef,
KnowledgeGlobalConfigModalRef,
} from '../types'
import Empty from '@/components/Empty'
import KnowledgeListModal from './KnowledgeListModal'
import KnowledgeConfigModal from './KnowledgeConfigModal'
import KnowledgeGlobalConfigModal from './KnowledgeGlobalConfigModal'
import Tag from '@/components/Tag'
const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig) => void}> = ({data, onUpdate}) => {
const { t } = useTranslation()
const knowledgeModalRef = useRef<KnowledgeModalRef>(null)
const knowledgeConfigModalRef = useRef<KnowledgeConfigModalRef>(null)
const knowledgeGlobalConfigModalRef = useRef<KnowledgeGlobalConfigModalRef>(null)
const [knowledgeList, setKnowledgeList] = useState<KnowledgeBase[]>([])
const [editConfig, setEditConfig] = useState<KnowledgeConfig>({} as KnowledgeConfig)
useEffect(() => {
if (data) {
setEditConfig({ ...(data || {}) })
const knowledge_bases = [...(data.knowledge_bases || [])]
setKnowledgeList(knowledge_bases)
onUpdate(prev => ({
...prev,
knowledge_bases: knowledge_bases,
}))
}
}, [data])
const handleKnowledgeConfig = () => {
knowledgeGlobalConfigModalRef.current?.handleOpen()
}
const handleAddKnowledge = () => {
knowledgeModalRef.current?.handleOpen()
}
const handleDeleteKnowledge = (id: string) => {
const list = knowledgeList.filter(item => item.id !== id)
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
knowledge_bases: [...list],
}))
}
const handleEditKnowledge = (item: KnowledgeBase) => {
knowledgeConfigModalRef.current?.handleOpen(item)
}
const refresh = (values: KnowledgeBase[] | KnowledgeConfigForm | RerankerConfig, type: 'knowledge' | 'knowledgeConfig' | 'rerankerConfig') => {
if (type === 'knowledge') {
let list = [...knowledgeList]
if (list.length > 0) {
(Array.isArray(values) ? values : [values]).forEach(vo => {
const index = list.findIndex(item => item.id === (vo as KnowledgeBase).id)
if (index === -1) {
list.push(vo as KnowledgeBase)
}
})
} else {
list = [...values as KnowledgeBase[]]
}
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
knowledge_bases: [...list],
}))
} else if (type === 'knowledgeConfig') {
const index = knowledgeList.findIndex(item => item.id === (values as KnowledgeBase).kb_id)
const list = [...knowledgeList]
list[index] = {
...list[index],
config: {...values as KnowledgeConfigForm}
}
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
knowledge_bases: [...list],
}))
} else if (type === 'rerankerConfig') {
setEditConfig(prev => ({ ...prev, ...(values as RerankerConfig) }))
onUpdate(prev => ({
...prev,
...values,
reranker_id: values.rerank_model ? values.reranker_id : undefined,
reranker_top_k: values.rerank_model ? values.reranker_top_k : undefined,
}))
}
}
return (
<Card
title={t('application.knowledgeBaseAssociation')}
extra={
<Button style={{padding: '0 8px', height: '24px'}} onClick={() => handleKnowledgeConfig()}>{t('application.globalConfig')}</Button>
}
>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-[12px]">
<div className="rb:font-medium rb:leading-[20px]">{t('application.associatedKnowledgeBase')}</div>
<Button style={{padding: '0 8px', height: '24px'}} onClick={handleAddKnowledge}>+{t('application.addKnowledgeBase')}</Button>
</div>
{knowledgeList.length === 0
? <Empty url={knowledgeEmpty} size={88} subTitle={t('application.knowledgeEmpty')} />
:
<List
grid={{ gutter: 12, column: 1 }}
dataSource={knowledgeList}
renderItem={(item) => (
<List.Item>
<div key={item.id} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-[8px]">
<div className="rb:font-medium rb:leading-[16px]">
{item.name}
<Tag color={item.status === 1 ? 'success' : item.status === 0 ? 'default' : 'error'} className="rb:ml-[8px]">
{item.status === 1 ? t('common.enable') : item.status === 0 ? t('common.disabled') : t('common.deleted')}
</Tag>
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[20px]">{t('application.contains', {include_count: item.doc_num})}</div>
</div>
<Space size={12}>
<div
className="rb:w-[24px] rb:h-[24px] rb:cursor-pointer rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEditKnowledge(item)}
></div>
<div
className="rb:w-[24px] rb:h-[24px] rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteKnowledge(item.id)}
></div>
</Space>
</div>
</List.Item>
)}
/>
}
{/* 全局设置 */}
<KnowledgeGlobalConfigModal
data={editConfig}
ref={knowledgeGlobalConfigModalRef}
refresh={refresh}
/>
{/* 知识库列表 */}
<KnowledgeListModal
ref={knowledgeModalRef}
selectedList={knowledgeList}
refresh={refresh}
/>
<KnowledgeConfigModal
ref={knowledgeConfigModalRef}
refresh={refresh}
/>
</Card>
)
}
export default Knowledge

View File

@@ -0,0 +1,180 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Form, Select, InputNumber } from 'antd';
import { useTranslation } from 'react-i18next';
import type { KnowledgeConfigModalRef, KnowledgeBase, KnowledgeConfigForm } from '../types'
import RbModal from '@/components/RbModal'
import RbSlider from '@/components/RbSlider'
import { formatDateTime } from '@/utils/format';
const FormItem = Form.Item;
interface KnowledgeConfigModalProps {
refresh: (values: KnowledgeConfigForm, type: 'knowledgeConfig') => void;
}
const retrieveTypes = ['participle', 'semantic', 'hybrid']
const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfigModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<KnowledgeConfigForm>();
const [data, setData] = useState<KnowledgeBase | null>(null);
const values = Form.useWatch<KnowledgeConfigForm>([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setData(null)
};
const handleOpen = (data: KnowledgeBase) => {
form.setFieldsValue({
retrieve_type: retrieveTypes[0],
kb_id: data.id,
...(data || {}),
...(data?.config || {}),
})
setData({...data})
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
refresh(values, 'knowledgeConfig')
handleClose()
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
useEffect(() => {
if (values?.retrieve_type) {
const initialValues = Object.keys(values).map(key => {
return {
[key as keyof KnowledgeConfigForm]: (key === 'kb_id' || key === 'retrieve_type') ? values[key] : undefined
}
})
form.resetFields(initialValues)
}
}, [values?.retrieve_type])
return (
<RbModal
title={t('application.knowledgeConfig')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
>
<Form
form={form}
layout="vertical"
>
{data && (
<div className="rb:mb-[24px] rb:flex rb:items-center rb:justify-between rb:border rb:rounded-[8px] rb:p-[17px_16px] rb:cursor-pointer rb:bg-[#F0F3F8] rb:border-[#DFE4ED] rb:text-[#212332]">
<div className="rb:text-[16px] rb:leading-[22px]">
{data.name}
<div className="rb:text-[12px] rb:leading-[16px] rb:text-[#5B6167] rb:mt-[8px]">{t('application.contains', {include_count: data.doc_num})}</div>
</div>
<div className="rb:text-[12px] rb:leading-[16px] rb:text-[#5B6167]">{formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss')}</div>
</div>
)}
<FormItem name="kb_id" hidden />
{/* 检索模式 */}
<FormItem
name="retrieve_type"
label={t('application.retrieve_type')}
extra={t('application.retrieve_type_desc')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
options={retrieveTypes.map(key => ({
label: t(`application.${key}`),
value: key,
}))}
/>
</FormItem>
{/* Top K */}
<FormItem
name="top_k"
label={t('application.top_k')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
extra={t('application.top_k_desc')}
>
<InputNumber style={{ width: '100%' }} />
</FormItem>
{/* 语义相似度阈值 similarity_threshold */}
{values?.retrieve_type === 'semantic' && (
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc')}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
)}
{/* 分词匹配度阈值 vector_similarity_weight */}
{values?.retrieve_type === 'participle' && (
<FormItem
name="vector_similarity_weight"
label={t('application.vector_similarity_weight')}
extra={t('application.vector_similarity_weight_desc')}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
)}
{/* 混合检索权重 */}
{values?.retrieve_type === 'hybrid' && (
<>
<FormItem
name="similarity_threshold"
label={t('application.similarity_threshold')}
extra={t('application.similarity_threshold_desc1')}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
<FormItem
name="vector_similarity_weight"
label={t('application.vector_similarity_weight')}
extra={t('application.vector_similarity_weight_desc1')}
>
<RbSlider
max={1.0}
step={0.1}
min={0.0}
/>
</FormItem>
</>
)}
</Form>
</RbModal>
);
});
export default KnowledgeConfigModal;

View File

@@ -0,0 +1,121 @@
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import { Form, InputNumber, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import type { RerankerConfig, KnowledgeGlobalConfigModalRef } from '../types'
import RbModal from '@/components/RbModal'
import CustomSelect from '@/components/CustomSelect'
import { getModelListUrl } from '@/api/models'
const FormItem = Form.Item;
interface KnowledgeGlobalConfigModalProps {
data: RerankerConfig;
refresh: (values: RerankerConfig, type: 'rerankerConfig') => void;
}
const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, KnowledgeGlobalConfigModalProps>(({
refresh,
data,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<RerankerConfig>();
const values = Form.useWatch<RerankerConfig>([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = () => {
form.setFieldsValue({ ...data, rerank_model: !!data?.reranker_id })
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
refresh(values, 'rerankerConfig')
handleClose()
})
.catch((err) => {
console.log('err', err)
});
}
useEffect(() => {
if (values?.rerank_model) {
form.setFieldsValue({ ...data })
} else {
form.setFieldsValue({ reranker_id: undefined, reranker_top_k: undefined })
}
}, [values?.rerank_model])
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={t('application.globalConfig')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
>
<Form
form={form}
layout="vertical"
>
<div className="rb:text-[#5B6167] rb:mb-[24px]">{t('application.globalConfigDesc')}</div>
{/* 结果重排 */}
<div className="rb:flex rb:items-center rb:justify-between rb:my-[24px]">
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px]">
{t('application.rerankModel')}
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px]">{t('application.rerankModelDesc')}</div>
</div>
<FormItem
name="rerank_model"
valuePropName="checked"
className="rb:mb-[0px]!"
>
<Switch />
</FormItem>
</div>
{values?.rerank_model && <>
<FormItem
name="reranker_id"
label={t('application.rearrangementModel')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
extra={t('application.rearrangementModelDesc')}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'rerank', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
/>
</FormItem>
{/* Top K */}
<FormItem
name="reranker_top_k"
label={t('application.reranker_top_k')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
extra={t('application.reranker_top_k_desc')}
>
<InputNumber style={{ width: '100%' }} min={1} max={20} />
</FormItem>
</>}
</Form>
</RbModal>
);
});
export default KnowledgeGlobalConfigModal;

View File

@@ -0,0 +1,147 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Space, List } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx'
import type { KnowledgeModalRef, KnowledgeBase } from '../types'
import type { KnowledgeBaseListItem } from '@/views/KnowledgeBase/types'
import RbModal from '@/components/RbModal'
import { getKnowledgeBaseList } from '@/views/KnowledgeBase/service'
import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format';
interface KnowledgeModalProps {
refresh: (rows: KnowledgeBase[], type: 'knowledge') => void;
selectedList: KnowledgeBase[];
}
const KnowledgeListModal = forwardRef<KnowledgeModalRef, KnowledgeModalProps>(({
refresh,
selectedList
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [list, setList] = useState<KnowledgeBaseListItem[]>([])
const [filterList, setFilterList] = useState<KnowledgeBaseListItem[]>([])
const [query, setQuery] = useState<{keywords?: string}>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectedRows, setSelectedRows] = useState<KnowledgeBase[]>([])
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setQuery({})
setSelectedIds([])
setSelectedRows([])
};
const handleOpen = () => {
setVisible(true);
setQuery({})
setSelectedIds([])
setSelectedRows([])
getList()
};
useEffect(() => {
getList()
}, [query.keywords])
const getList = () => {
getKnowledgeBaseList(undefined, {
...query,
pagesize: 100,
orderby:'created_at',
desc:true,
})
.then(res => {
const response = res as { items: KnowledgeBaseListItem[] }
setList(response.items || [])
})
}
// 封装保存方法,添加提交逻辑
const handleSave = () => {
refresh(selectedRows.map(item => ({
...item,
config: {
similarity_threshold: 0.7,
strategy: "hybrid",
top_k: 3,
weight: 1,
}
})), 'knowledge')
setVisible(false);
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
const handleSearch = (value?: string) => {
setQuery({keywords: value})
setSelectedIds([])
setSelectedRows([])
}
const handleSelect = (item: KnowledgeBase) => {
const index = selectedIds.indexOf(item.id)
if (index === -1) {
setSelectedIds([...selectedIds, item.id])
setSelectedRows([...selectedRows, item])
} else {
setSelectedIds(selectedIds.filter(id => id !== item.id))
setSelectedRows(selectedRows.filter(row => row.id !== item.id))
}
}
useEffect(() => {
if (list.length && selectedList.length) {
const unSelectedList = list.filter(item => selectedList.findIndex(vo => vo.id === item.id) < 0)
setFilterList([...unSelectedList])
} else if (list.length) {
setFilterList([...list])
}
}, [list, selectedList])
return (
<>
<RbModal
title={t('application.chooseKnowledge')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
width={1000}
>
<Space size={24} direction="vertical" className="rb:w-full">
<SearchInput
placeholder={t('knowledgeBase.searchPlaceholder')}
onSearch={handleSearch}
style={{ width: '100%' }}
/>
{filterList.length === 0
? <Empty />
: <List
grid={{ gutter: 16, column: 2 }}
dataSource={filterList}
renderItem={(item: KnowledgeBase) => (
<List.Item>
<div key={item.id} className={clsx("rb:flex rb:items-center rb:justify-between rb:border rb:rounded-[8px] rb:p-[17px_16px] rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": selectedIds.includes(item.id),
"rb:border-[#DFE4ED] rb:text-[#212332]": !selectedIds.includes(item.id),
})} onClick={() => handleSelect(item)}>
<div className="rb:text-[16px] rb:leading-[22px]">
{item.name}
<div className="rb:text-[12px] rb:leading-[16px] rb:text-[#5B6167] rb:mt-[8px]">{t('application.contains', {include_count: item.doc_num})}</div>
</div>
<div className="rb:text-[12px] rb:leading-[16px] rb:text-[#5B6167]">{formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')}</div>
</div>
</List.Item>
)}
/>
}
</Space>
</RbModal>
</>
);
});
export default KnowledgeListModal;

View File

@@ -0,0 +1,43 @@
import { useEffect, useState, type FC } from 'react';
import { useTranslation } from 'react-i18next';
import { Cascader } from 'antd'
import type { CascaderProps } from 'antd';
import { getModelProviderList } from '@/api/models'
interface Option {
value?: string | number | null;
label: React.ReactNode;
children?: Option[];
isLeaf?: boolean;
}
const CustomSelect: FC<CascaderProps> = () => {
const { t } = useTranslation();
const [options, setOptions] = useState<Option[]>([]);
useEffect(() => {
getProviderList()
}, []);
const getProviderList = () => {
getModelProviderList().then(res => {
const response = res as string[]
setOptions(response.map((key: string) => ({
value: key,
label: t(`model.${key}`),
children: [],
isLeaf: false,
})))
})
}
const loadData = (selectedOptions: Option[]) => {
const targetOption = selectedOptions[selectedOptions.length - 1];
console.log(targetOption)
}
return (
<Cascader
options={options}
loadData={loadData}
changeOnSelect
/>
);
}
export default CustomSelect;

View File

@@ -0,0 +1,147 @@
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import { Form, Select } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ModelConfig, ModelConfigModalRef, Config, ChatData } from '../types'
import type { Model } from '@/views/ModelManagement/types'
import RbModal from '@/components/RbModal'
import RbSlider from '@/components/RbSlider'
const FormItem = Form.Item;
interface ModelConfigModalProps {
modelList: Model[];
refresh: (values: ModelConfig, type: 'model') => void;
data: Config;
chatList: ChatData[]
}
const configFields = [
{ key: 'temperature', max: 2, min: 0, step: 0.1, defaultValue: 0.7 },
{ key: 'max_tokens', max: 32000, min: 256, step: 1, defaultValue: 2000 },
{ key: 'top_p', max: 1, min: 0, step: 0.1, defaultValue: 1.0 },
{ key: 'frequency_penalty', max: 2.0, min: -2.0, step: 0.1, defaultValue: 0.0 },
{ key: 'presence_penalty', max: 2.0, min: -2.0, step: 0.1, defaultValue: 0.0 },
{ key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 },
]
const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(({
refresh,
data,
modelList
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ModelConfig>();
const [source, setSource] = useState<'chat' | 'model'>('model')
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = (source: 'chat' | 'model', model) => {
setSource(source)
if (source === 'model') {
form.setFieldsValue({
...(data?.model_parameters || {}),
default_model_config_id: data.default_model_config_id || ''
})
} else if (source === 'chat') {
if (model) {
form.setFieldsValue({
...(model?.model_parameters || {}),
default_model_config_id: model.default_model_config_id || ''
})
} else {
form.setFieldsValue({
...(data?.model_parameters || {}),
default_model_config_id: undefined
})
}
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
refresh(values, source)
handleClose()
})
.catch((err) => {
console.log('err', err)
});
}
const handleChange = (value: string, option: Model) => {
if (source === 'chat') {
form.setFieldValue('label', option.name)
}
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
useEffect(() => {
form.setFieldsValue({...(data?.model_parameters || {})})
}, [values?.default_model_config_id])
return (
<RbModal
title={t('application.modelConfig')}
open={visible}
onCancel={handleClose}
cancelText={t('application.resetDefault')}
okText={t('application.apply')}
onOk={handleSave}
>
<Form
form={form}
layout="vertical"
className="rb:ml-[7px]!"
>
<FormItem
name="default_model_config_id"
label={t('application.currentModel')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
placeholder={t('common.pleaseSelect')}
fieldNames={{
label: 'name',
value: 'id',
}}
options={modelList}
onChange={handleChange}
/>
</FormItem>
{source === 'chat' && <FormItem name="label" hidden />}
<div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('application.parameterConfig')}</div>
{configFields.map(item => (
<FormItem
key={item.key}
name={item.key}
label={t(`application.${item.key}`)}
extra={t(`application.${item.key}_desc`)}
>
<RbSlider
max={item.max}
step={item.step}
min={item.min}
/>
</FormItem>
))}
</Form>
</RbModal>
);
});
export default ModelConfigModal;

View File

@@ -0,0 +1,100 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ReleaseModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { publishRelease } from '@/api/application'
import type { Application } from '@/views/ApplicationManagement/types'
const FormItem = Form.Item;
interface ReleaseModalProps {
refreshTable: () => void;
data: Application
}
const ReleaseModal = forwardRef<ReleaseModalRef, ReleaseModalProps>(({
refreshTable,
data
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then(() => {
setLoading(true)
const values = form.getFieldsValue()
publishRelease(data.id, values)
.then(() => {
handleClose()
refreshTable()
})
.finally(() => {
setLoading(false)
})
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<>
<RbModal
title={t('application.releaseNewVersion')}
open={visible}
onCancel={handleClose}
okText={t('application.release')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
{/* 版本名 */}
<FormItem
name="version_name"
label={t('application.versionName')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
]}
extra={t('application.versionNameTip')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
{/* 版本描述 */}
<FormItem
name="release_notes"
label={t('application.versionDescription')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
]}
extra={t('application.versionDescriptionTip')}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
</Form>
</RbModal>
</>
);
});
export default ReleaseModal;

View File

@@ -0,0 +1,84 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Button, App } from 'antd';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import copy from 'copy-to-clipboard'
import type { Release, ReleaseShareModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { shareRelease } from '@/api/application'
import RbAlert from '@/components/RbAlert'
interface ReleaseShareModalProps {
version: Release | null
}
const ReleaseShareModal = forwardRef<ReleaseShareModalRef, ReleaseShareModalProps>(({
version
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp()
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [shareLink, setShareLink] = useState<string | null>(null)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setLoading(false)
};
const handleOpen = () => {
if (!version) {
return
}
setLoading(true)
shareRelease(version?.app_id, version.id || '')
.then(res => {
const response = res as { share_token: string }
if (response?.share_token) {
setShareLink(`${window.location.origin}/#/conversation/${response?.share_token}`)
setVisible(true);
}
})
.finally(() => {
setLoading(false)
})
};
const handleCopy = () => {
if (!shareLink) return
copy(shareLink)
message.success(t('common.copySuccess'))
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={<>{t('application.shareVersion')} {version?.version}</>}
open={visible}
onCancel={handleClose}
footer={false}
>
<>
<div className="rb:leading-[20px] rb:mb-[8px]">{t('application.shareLink')}</div>
<div className="rb:mb-[12px] rb:flex rb:items-center rb:gap-[10px] rb:justify-between">
<div className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis rb:cursor-pointer rb:h-[32px] rb:p-[6px_10px] rb:bg-[#FFFFFF] rb:border rb:border-[#EBEBEB] rb:rounded-[6px] rb:leading-[20px]">{shareLink}</div>
<Button type="primary" loading={loading} disabled={!shareLink} onClick={handleCopy}>
{t('common.copy')}
</Button>
</div>
<RbAlert color="orange" icon={<ExclamationCircleFilled />}>
{t('application.shareLinkTip')}
</RbAlert>
</>
</RbModal>
);
});
export default ReleaseShareModal;

View File

@@ -0,0 +1,116 @@
import { forwardRef, useImperativeHandle, useState, type Key } from 'react';
import { Form, Select, Input } from 'antd';
import type { DefaultOptionType } from 'antd/es/select'
import { useTranslation } from 'react-i18next';
import type { SubAgentModalRef, SubAgentItem } from '../types'
import RbModal from '@/components/RbModal'
import CustomSelect from '@/components/CustomSelect';
import { getApplicationListUrl } from '@/api/application';
const FormItem = Form.Item;
interface SubAgentModalProps {
refresh: (agent: SubAgentItem) => void;
}
const SubAgentModal = forwardRef<SubAgentModalRef, SubAgentModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
const [editVo, setEditVo] = useState<SubAgentItem>()
const values = Form.useWatch([], form)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = (agent?: SubAgentItem) => {
setVisible(true);
form.setFieldsValue(agent)
setEditVo(agent)
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then(() => {
setLoading(false)
refresh(values)
handleClose()
})
}
const handleChange = (value: Key, option?: DefaultOptionType | DefaultOptionType[] | undefined) => {
console.log(value, option)
if (option && !Array.isArray(option)) {
form.setFieldsValue({ name: option.children })
}
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`application.${editVo?.agent_id ? 'updateSubAgent' : 'addSubAgent'}`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
{/* Agent名称 */}
<FormItem
name="agent_id"
label={t('application.agentName')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
]}
>
<CustomSelect
url={getApplicationListUrl}
params={{ pagesize: 100, status: 'active', type: 'agent' }}
valueKey="id"
labelKey="name"
hasAll={false}
optionFilterProp="search"
showSearch={true}
onChange={handleChange}
/>
</FormItem>
<FormItem name="name" hidden />
{/* 描述 */}
<FormItem
name="role"
label={t('application.description')}
>
<Input.TextArea placeholder={t('common.pleaseEnter')} />
</FormItem>
{/* 关键词 */}
<FormItem
name="capabilities"
label={t('application.capabilities')}
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder={t('common.pleaseEnter')}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default SubAgentModal;

View File

@@ -0,0 +1,23 @@
import { type FC, type ReactNode } from 'react'
export interface TagProps {
color?: 'processing' | 'warning' | 'default' | 'success';
children: ReactNode;
className?: string;
}
const colors = {
processing: 'rb:text-[#155EEF] rb:border-[rgba(21,94,239,0.25)] rb:bg-[rgba(21,94,239,0.06)]',
warning: 'rb:text-[#FF5D34] rb:border-[rgba(255,93,52,0.08)] rb:bg-[rgba(255,93,52,0.08)]',
default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]',
success: 'rb:text-[#369F21] rb:border-[rgba(54,159,33,0.30)] rb:bg-[rgba(54,159,33,0.08)]',
}
const Tag: FC<TagProps> = ({ color = 'processing', children, className }) => {
return (
<span className={`rb:inline-block rb:px-[8px] rb:py-[2px] rb:rounded-[11px] rb:text-[12px] rb:font-regular rb:leading-[16px] rb:border-[1px] ${colors[color]} ${className || ''}`}>
{children}
</span>
)
}
export default Tag

View File

@@ -0,0 +1,232 @@
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Form, Input, Select, InputNumber, Checkbox, Tag, Divider, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ApiExtensionModalRef, Variable, VariableEditModalRef } from '../types'
import RbModal from '@/components/RbModal'
import SortableList from '@/components/SortableList'
import ApiExtensionModal from './ApiExtensionModal'
const FormItem = Form.Item;
interface VariableEditModalProps {
refreshTable: (values: Variable) => void;
}
const types = [
'text',
'paragraph',
// 'dropdownOptions',
'number',
// 'checkbox',
// 'apiVariable'
]
const variableType = {
text: 'string',
paragraph: 'string',
dropdownOptions: 'string',
number: 'number',
checkbox: 'boolean',
apiVariable: 'string',
}
const VariableEditModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<Variable>();
const [loading, setLoading] = useState(false)
const [editVo, setEditVo] = useState<Variable | null>(null)
const apiExtensionModalRef = useRef<ApiExtensionModalRef>(null)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditVo(null)
};
const handleOpen = (variable?: Variable) => {
setVisible(true);
if (variable) {
setEditVo(variable || null)
form.setFieldsValue(variable)
} else {
form.resetFields();
}
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then((values) => {
refreshTable({
...(editVo || {}),
...values,
})
handleClose()
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
// 变量类型改变时,更新初始化其他字段值
const handleChangeType = (value: Variable['type']) => {
if (value) {
form.setFieldsValue({
type: value,
name: undefined,
display_name: undefined,
description: undefined,
max_length: undefined,
options: undefined,
api_extension: undefined,
// default_value: undefined
})
}
}
// 添加 API 扩展
const addApiExtension = () => {
apiExtensionModalRef.current?.handleOpen()
}
const refreshApiExtensionList = () => {}
return (
<>
<RbModal
title={t('application.editVariable')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
>
{/* 变量类型 */}
<FormItem
name="type"
label={t('application.variableType')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
placeholder={t('common.pleaseSelect')}
options={types.map(key => ({
value: key,
label: t(`application.${key}`),
}))}
onChange={handleChangeType}
labelRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
optionRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
/>
</FormItem>
{/* 变量名称 */}
<FormItem
name="name"
label={t('application.variableName')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('application.invalidVariableName') },
]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
{/* 显示名称 */}
<FormItem
name="display_name"
label={t('application.displayName')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
{/* 描述 */}
<FormItem
name="description"
label={t('application.description')}
>
<Input placeholder={t('common.enter')} />
</FormItem>
{/* 最大长度 */}
{['text', 'paragraph'].includes(values?.type) && (
<FormItem
name="max_length"
label={t('application.maxLength')}
>
<InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
</FormItem>
)}
{/* 默认值 */}
{/* {['text', 'paragraph', 'number', 'checkbox'].includes(values?.type) && (
<FormItem
name="default_value"
label={t('application.defaultValue')}
>
{['text'].includes(values.type) && <Input placeholder={t('common.enter')} />}
{['paragraph'].includes(values.type) && <Input.TextArea placeholder={t('common.enter')} />}
{['number'].includes(values.type) && <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />}
{['checkbox'].includes(values.type) && <Select options={[{ value: true, label: t('application.defaultChecked') }, { value: false, label: t('application.notDefaultChecked') }]} />}
</FormItem>
)} */}
{/* 选项 */}
{['dropdownOptions'].includes(values?.type) && (
<FormItem
name="options"
label={t('application.options')}
>
<SortableList />
</FormItem>
)}
{/* API 扩展 */}
{['apiVariable'].includes(values?.type) && (
<FormItem
name="api_extension"
label={t('application.apiExtension')}
>
<Select
placeholder={t('common.pleaseSelect')}
popupRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Button type="text" block onClick={addApiExtension}>
Add item
</Button>
</>
)}
/>
</FormItem>
)}
{/* 是否必填 */}
<FormItem
name="required"
valuePropName="checked"
>
<Checkbox>{t('application.required')}</Checkbox>
</FormItem>
{/* 是否隐藏 */}
{/* <FormItem
name="hidden"
valuePropName="checked"
>
<Checkbox>{t('application.hidden')}</Checkbox>
</FormItem> */}
</Form>
</RbModal>
<ApiExtensionModal
ref={apiExtensionModalRef}
refresh={refreshApiExtensionList}
/>
</>
);
});
export default VariableEditModal;

View File

@@ -0,0 +1,131 @@
import { type FC, useRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, Switch } from 'antd'
import variablesEmpty from '@/assets/images/application/variablesEmpty.svg'
import Card from './Card'
import Table from '@/components/Table';
import type { Variable, VariableEditModalRef } from '../types'
import Empty from '@/components/Empty'
import VariableEditModal from './VariableEditModal'
interface VariableListProps {
data?: Variable[];
onUpdate: (data: Variable[]) => void;
}
const VariableList: FC<VariableListProps> = ({data = [], onUpdate}) => {
const { t } = useTranslation()
const variableEditModalRef = useRef<VariableEditModalRef>(null)
const [variableList, setVariableList] = useState<Variable[]>([])
const [maxIndex, setMaxIndex] = useState(0)
useEffect(() => {
if (!data || data.length === 0) return
const list = data.map((item, index) => ({
...item,
index
}))
setVariableList(list)
onUpdate(list)
setMaxIndex(list.length)
}, [data])
const handleAddVariable = () => {
variableEditModalRef.current?.handleOpen()
}
const handleSaveVariable = (value: Variable) => {
if (value.index !== undefined && value.index >= 0) {
const index = variableList.findIndex(item => item.index === value.index)
if (index !== -1) {
const newData = [...variableList]
newData[index] = value
setVariableList([...newData])
onUpdate([...newData])
}
} else {
const list = [...variableList, {
index: maxIndex + 1,
...value
}]
setVariableList(list)
onUpdate([...list])
setMaxIndex(maxIndex + 1)
}
}
const handleDeleteVariable = (index: number) => {
const list = variableList.filter((_, i) => i !== index)
setVariableList(list)
onUpdate([...list])
}
return (
<Card title={t('application.variableConfiguration')}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-[11px]">
<div className="rb:font-medium rb:leading-[20px]">
{t('application.VariableManagement')}
<span className="rb:font-regular rb:text-[12px] rb:text-[#5B6167]"> ({t('application.VariableManagementDesc')})</span>
</div>
<Button style={{padding: '0 8px', height: '24px'}} onClick={handleAddVariable}>+{t('application.addVariables')}</Button>
</div>
{/* List */}
{variableList.length > 0
? (
<div className="rb:mt-[12px]">
<Table
rowKey="index"
pagination={false}
columns={[
{
title: t('application.variableType'),
dataIndex: 'type',
key: 'type',
render: (type) => t(`application.${type}`)
},
{
title: t('application.variableKey'),
dataIndex: 'name',
key: 'name',
},
{
title: t('application.variableName'),
dataIndex: 'display_name',
key: 'display_name',
},
{
title: t('application.optional'),
dataIndex: 'required',
key: 'required',
render: (required) => <Switch checked={!required} disabled />
},
{
title: t('common.operation'),
key: 'action',
render: (_, record, index: number) => (
<Space size="middle">
<Button
type="link"
onClick={() => variableEditModalRef.current?.handleOpen(record as Variable)}
>
{t('common.edit')}
</Button>
<Button type="link" danger onClick={() => handleDeleteVariable(index)}>
{t('common.delete')}
</Button>
</Space>
),
},
]}
initialData={variableList as unknown as Record<string, unknown>[]}
emptySize={88}
/>
</div>
)
: <Empty url={variablesEmpty} size={88} subTitle={t('application.variablesEmpty')} />
}
<VariableEditModal
ref={variableEditModalRef}
refreshTable={handleSaveVariable}
/>
</Card>
)
}
export default VariableList

View File

@@ -0,0 +1,29 @@
.tabs {
height: 100%;
}
.tabs :global(.ant-tabs-nav) {
margin-bottom: 0;
padding: 0;
}
.tabs :global(.ant-tabs-tab) {
line-height: 20px;
padding-bottom: 18px;
padding-top: 10px;
}
.tabs :global(.ant-tabs-tab-active) {
font-weight: 500;
}
.tabs :global(.ant-tabs-tab+.ant-tabs-tab) {
margin-left: 78px;
}
.tabs:global(.ant-tabs-top>.ant-tabs-nav .ant-tabs-ink-bar),
.tabs:global(.ant-tabs-top>div>.ant-tabs-nav .ant-tabs-ink-bar) {
height: 4px;
}
.tabs:global(.ant-tabs-top>.ant-tabs-nav::before),
.tabs:global(.ant-tabs-top>div>.ant-tabs-nav::before) {
border-bottom: 0;
}
.tabs:global(.ant-tabs .ant-tabs-tab) {
color: #5B6167;
}

View File

@@ -0,0 +1,61 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams } from 'react-router-dom';
import ConfigHeader from './components/ConfigHeader'
import type { AgentRef } from './types'
import type { Application } from '@/views/ApplicationManagement/types'
import Agent from './Agent'
import Api from './Api'
import ReleasePage from './ReleasePage'
import Cluster from './Cluster'
import { getApplication } from '@/api/application'
import { randomString } from '@/utils/common'
const apiKeyList = [`app-${randomString(24, false)}`]
const ApplicationConfig: React.FC = () => {
const { id } = useParams();
const agentRef = useRef<AgentRef>(null)
const [application, setApplication] = useState<Application | null>(null);
const [activeTab, setActiveTab] = useState('arrangement');
const handleChangeTab = async (key: string) => {
if (activeTab === 'arrangement' && application?.type === 'agent' && agentRef.current) {
agentRef.current.handleSave(false)
.then(() => {
setActiveTab(key)
})
} else {
setActiveTab(key)
}
}
useEffect(() => {
getApplicationInfo()
}, [id])
const getApplicationInfo = () => {
if (!id) {
return
}
getApplication(id as string).then(res => {
const response = res as Application
setApplication(response)
})
}
return (
<>
<ConfigHeader
activeTab={activeTab}
handleChangeTab={handleChangeTab}
application={application as Application}
refresh={getApplicationInfo}
/>
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster application={application as Application} />}
{activeTab === 'api' && <Api apiKeyList={apiKeyList} />}
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
</>
);
};
export default ApplicationConfig;

View File

@@ -0,0 +1,194 @@
import type { KnowledgeBaseListItem } from '@/views/KnowledgeBase/types'
export interface ModelConfig {
label?: string;
default_model_config_id?: string;
temperature: number;
max_tokens: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
n: number;
stop?: string;
}
/*************** 知识库相关 ******************/
export interface RerankerConfig {
rerank_model?: boolean | undefined;
reranker_id?: string | undefined;
reranker_top_k?: number | undefined;
}
export interface KnowledgeConfigForm {
kb_id?: string;
similarity_threshold?: number;
vector_similarity_weight?: number;
top_k?: number;
retrieve_type?: 'participle' | 'semantic' | 'hybrid';
}
export interface KnowledgeBase extends KnowledgeBaseListItem, KnowledgeConfigForm {
config?: KnowledgeConfigForm
}
export interface KnowledgeConfig extends RerankerConfig {
knowledge_bases: KnowledgeBase[];
}
export interface KnowledgeConfigModalRef {
handleOpen: (data: KnowledgeBase) => void;
}
export interface KnowledgeGlobalConfigModalRef {
handleOpen: () => void;
}
/*********** end 知识库相关 ******************/
/*************** 变量相关 ******************/
export interface Variable {
index?: number;
name: string;
display_name: string;
type: string;
required: boolean;
max_length?: number;
description?: string;
key: string;
default_value?: string;
options?: string[];
api_extension?: string;
hidden?: boolean;
}
export interface VariableEditModalRef {
handleOpen: (values?: Variable) => void;
}
/*********** end 变量相关 ******************/
export interface MemoryConfig {
enabled: boolean;
memory_content?: string;
max_history?: number | string;
}
export interface Config extends MultiAgentConfig {
id: string;
app_id: string;
system_prompt: string;
default_model_config_id?: string;
model_parameters: ModelConfig;
knowledge_retrieval: KnowledgeConfig | null;
memory?: MemoryConfig;
variables: Variable[];
tools: {
web_search: {
enabled: boolean;
config: {
web_search: boolean;
}
}
};
is_active: boolean;
created_at: number;
updated_at: number;
}
export interface MultiAgentConfig {
id: string;
app_id: string;
// system_prompt: string;
// default_model_config_id?: string;
// model_parameters: ModelConfig;
// knowledge_retrieval: KnowledgeConfig | null;
// memory?: MemoryConfig;
// variables: Variable[];
// tools: Record<string, string>;
// is_active: boolean;
// created_at: number;
// updated_at: number;
master_agent_id?: string;
sub_agents?: SubAgentItem[];
}
// 创建表单数据类型
export interface ApplicationModalData {
name: string;
type: string;
icon: string;
}
// 定义组件暴露的方法接口
export interface AgentRef {
handleSave: (flag?: boolean) => Promise<any>;
}
export interface ApplicationModalRef {
handleOpen: (application?: Config) => void;
}
export interface ModelConfigModalRef {
handleOpen: (source: 'chat' | 'model') => void;
}
export interface ModelConfigModalData {
model: string;
[key: string]: string;
}
export interface AiPromptModalRef {
handleOpen: (application?: Config) => void;
}
export interface KnowledgeModalRef {
handleOpen: (config?: KnowledgeConfig[]) => void;
}
export interface ApiExtensionModalData {
name: string;
apiEndpoint: string;
apiKey: string;
}
export interface ApiExtensionModalRef {
handleOpen: () => void;
}
export interface ChatItem {
role: 'answer' | 'question';
content?: string;
time: number;
}
export interface ChatData {
label?: string;
model_config_id?: string;
model_parameters?: ModelConfig;
list?: ChatItem[];
conversation_id?: string | null;
}
export interface Release {
id: string;
app_id: string;
version: string;
release_notes: string;
name: string;
description?: string;
icon: string;
icon_type?: string;
type: string;
visibility: string;
config: Config;
default_model_config_id?: string;
published_by?: string;
published_at: number;
publisher_name?: string;
is_active?: boolean;
created_at?: number;
updated_at?: number;
status?: string;
version_name?: string;
tagKey: 'current' | 'rolledBack' | 'history';
}
export interface ReleaseModalRef {
handleOpen: () => void;
}
export interface ReleaseShareModalRef {
handleOpen: () => void;
}
export interface CopyModalRef {
handleOpen: () => void;
}
export interface SubAgentItem {
agent_id: string;
name: string;
role: string;
capabilities: string[];
}
export interface SubAgentModalRef {
handleOpen: (agent?: SubAgentItem) => void;
}

View File

@@ -0,0 +1,139 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import RadioGroupCard from '@/components/RadioGroupCard'
import AgentIcon from '@/assets/images/application/agent.svg'
import ClusterIcon from '@/assets/images/application/cluster.svg'
import WorkflowIcon from '@/assets/images/application/workflow.svg'
import type { ApplicationModalData, ApplicationModalRef, Application } from '../types'
import RbModal from '@/components/RbModal'
import { addApplication, updateApplication } from '@/api/application'
const FormItem = Form.Item;
interface ApplicationModalProps {
refresh: () => void;
}
const types = [
'agent',
'multi_agent',
'workflow'
]
const typeIcons: Record<string, string> = {
agent: AgentIcon,
multi_agent: ClusterIcon,
workflow: WorkflowIcon
}
const ApplicationModal = forwardRef<ApplicationModalRef, ApplicationModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ApplicationModalData>();
const [loading, setLoading] = useState(false)
const [editVo, setEditVo] = useState<Application | null>(null)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditVo(null)
};
const handleOpen = (application?: Application) => {
if (application) {
setEditVo(application || null)
form.setFieldsValue({
name: application.name,
type: application.type,
description: application.description,
})
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
const response = editVo?.id ? updateApplication(editVo.id, {
...editVo,
...values,
} as Application) : addApplication(values as Application)
response.then(() => {
refresh()
handleClose()
})
.finally(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`application.${editVo?.id ? 'editApplication' : 'createApplication'}`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="name"
label={t('application.applicationName')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="description"
label={t('application.description')}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="type"
label={t('application.applicationType')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<RadioGroupCard
options={types.map((type) => ({
value: type,
label: t(`application.${type}`),
labelDesc: t(`application.${type}Desc`),
icon: typeIcons[type],
disabled: editVo?.id || type === 'workflow'
}))}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default ApplicationModal;

View File

@@ -0,0 +1,114 @@
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Row, Col, App } from 'antd';
import clsx from 'clsx';
import { DeleteOutlined } from '@ant-design/icons';
import type { Application, ApplicationModalRef } from './types';
import ApplicationModal from './components/ApplicationModal';
import SearchInput from '@/components/SearchInput'
import RbCard from '@/components/RbCard/Card'
import { getApplicationListUrl, deleteApplication } from '@/api/application'
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
import { formatDateTime } from '@/utils/format';
const ApplicationManagement: React.FC = () => {
const { t } = useTranslation();
const { modal } = App.useApp();
const [query, setQuery] = useState({});
const applicationModalRef = useRef<ApplicationModalRef>(null);
const scrollListRef = useRef<PageScrollListRef>(null)
const refresh = () => {
scrollListRef.current?.refresh();
}
const handleCreate = () => {
applicationModalRef.current?.handleOpen();
}
const handleEdit = (item: Application) => {
window.open(`/#/application/config/${item.id}`);
}
const handleDelete = (item: Application) => {
modal.confirm({
title: t('common.confirmDeleteDesc', { name: item.name }),
okText: t('common.delete'),
okType: 'danger',
onOk: () => {
deleteApplication(item.id)
.then(() => {
refresh();
})
.catch(() => {
console.error('Failed to delete application');
});
}
})
}
return (
<>
<Row gutter={16} className="rb:mb-[16px]">
<Col span={12}>
<SearchInput
placeholder={t('application.searchPlaceholder')}
onSearch={(value) => setQuery({ search: value })}
style={{width: '100%'}}
/>
</Col>
<Col span={12} className="rb:text-right">
<Button type="primary" onClick={handleCreate}>
{t('application.createApplication')}
</Button>
</Col>
</Row>
<PageScrollList
ref={scrollListRef}
url={getApplicationListUrl}
query={query}
renderItem={(item: Application) => (
<RbCard
title={item.name}
avatar={
<div className="rb:w-[48px] rb:h-[48px] rb:rounded-[8px] rb:mr-[13px] rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{item.name[0]}
</div>
}
>
{['type', 'source', 'created_at'].map((key, index) => (
<div key={key} className={clsx("rb:flex rb:justify-between rb:gap-[20px] rb:font-regular rb:text-[14px]", {
'rb:mt-[12px]': index !== 0
})}>
<span className="rb:text-[#5B6167]">{t(`application.${key}`)}</span>
<span className={clsx({
'rb:text-[#155EEF] rb:font-medium': key === 'type' && item[key] === 'agent',
'rb:text-[#369F21] rb:font-medium': key === 'type' && item[key] === 'multi_agent',
})}>
{key === 'source' && item.is_shared
? t('application.shared')
: key === 'source' && !item.is_shared
? t('application.configuration')
: key === 'created_at'
? formatDateTime(item[key as keyof Application], 'YYYY-MM-DD HH:mm:ss')
: t(`application.${item[key as keyof Application]}`)
}
</span>
</div>
))}
<div className="rb:mt-[20px] rb:flex rb:justify-between rb:gap-[10px]">
<Button type="primary" ghost className="rb:w-[calc(100%-46px)]" onClick={() => handleEdit(item)}>{t('application.configuration')}</Button>
<Button icon={<DeleteOutlined />} onClick={() => handleDelete(item)}></Button>
</div>
</RbCard>
)}
/>
<ApplicationModal
ref={applicationModalRef}
refresh={refresh}
/>
</>
);
};
export default ApplicationManagement;

View File

@@ -0,0 +1,73 @@
// 应用数据类型
export interface Application {
id: string;
workspace_id: string;
created_by: string;
name: string;
description?: string;
icon?: string;
icon_type?: string;
type: string;
visibility: string;
status: string;
tags: string[];
current_release_id?: string;
is_active: boolean;
is_shared: boolean;
created_at: number;
updated_at: number;
}
// 创建表单数据类型
export interface ApplicationModalData {
name: string;
type: string;
description?: string;
icon: {
url: string;
uid: string | number;
}[];
}
// 定义组件暴露的方法接口
export interface ApplicationModalRef {
handleOpen: (application?: Application) => void;
}
export interface ModelConfigModalRef {
handleOpen: (application?: Application) => void;
}
export interface ModelConfigModalData {
model: string;
[key: string]: string;
}
export interface AiPromptModalRef {
handleOpen: (application?: Application) => void;
}
export interface VariableModalRef {
handleOpen: (application?: Application) => void;
}
export interface VariableModalProps {
refresh: () => void;
}
export interface VariableEditModalRef {
handleOpen: (values?: Variable) => void;
}
export interface Variable {
index?: number;
type: string;
key: string;
name: string;
maxLength?: number;
defaultValue?: string;
options?: string[];
required: boolean;
hidden?: boolean;
}
export interface ApiExtensionModalData {
name: string;
apiEndpoint: string;
apiKey: string;
}
export interface ApiExtensionModalRef {
handleOpen: () => void;
}

View File

@@ -0,0 +1,256 @@
import { type FC, useState, useEffect, useRef } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component';
import { Flex, Skeleton } from 'antd'
import clsx from 'clsx'
import Chat, { type ChatItem } from '@/views/MemoryConversation/components/Chat'
import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.svg'
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken } from '@/api/application'
import type { HistoryItem } from './types'
import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format';
import { randomString } from '@/utils/common'
import BgImg from '@/assets/images/conversation/bg.png'
const Conversation: FC = () => {
const { t } = useTranslation()
const { token } = useParams()
const location = useLocation()
const searchParams = new URLSearchParams(location.search)
const userId = searchParams.get('user_id')
const [loading, setLoading] = useState(false)
const [chatLoading, setChatLoading] = useState(false)
const [query, setQuery] = useState<{
message?: string;
web_search?: boolean;
memory?: boolean;
conversation_id?: string;
}>({})
const [conversation_id, setConversationId] = useState<string | null>(null)
const [historyList, setHistoryList] = useState<HistoryItem[]>([])
const [groupHistoryList, setGroupHistoryList] = useState<Record<string, HistoryItem[]>>({})
const [chatList, setChatList] = useState<ChatItem[]>([])
const [pageLoading, setPageLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
useEffect(() => {
const shareToken = localStorage.getItem(`shareToken_${token}`)
setShareToken(shareToken)
if (shareToken && shareToken !== '') return
getShareToken(token as string, userId || randomString(12, false))
.then(res => {
localStorage.setItem(`shareToken_${token}`, res?.access_token || '')
setShareToken(res?.access_token || '')
})
}, [token])
useEffect(() => {
if (token && page === 1 && hasMore && historyList.length === 0 && shareToken) {
getHistory()
}
}, [token, shareToken, page, hasMore, historyList])
// 按日期分组历史记录
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
return items.reduce((groups: Record<string, HistoryItem[]>, item) => {
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
if (!groups[date]) {
groups[date] = [];
}
groups[date].push(item);
return groups;
}, {});
}
const getHistory = (flag: boolean = false) => {
if (!token || (pageLoading || !hasMore) && !flag) {
return
}
setPageLoading(true);
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
.then(res => {
const response = res as { items: HistoryItem[], page: { hasnext: boolean } }
const results = response?.items || []
let list = []
if (flag) {
setHistoryList(results);
list = [...results]
} else {
setHistoryList(historyList.concat(results));
list = [...historyList, ...results]
}
setHistoryList(list)
setGroupHistoryList(groupHistoryByDate(list))
if (page === 1 && !flag) {
setConversationId(list[0]?.id || '')
}
setPage(response.page.page + 1);
setHasMore(response.page.hasnext);
setLoading(false);
})
.finally(() => {
setPageLoading(false);
})
}
const handleChangeHistory = (id: string | null) => {
if (id !== conversation_id) {
setConversationId(id)
}
if (!id) {
setQuery({})
}
}
useEffect(() => {
if (conversation_id) {
getConversationDetail(token as string, conversation_id)
.then(res => {
const response = res as { messages: ChatItem[] }
setChatList(response?.messages || [])
})
} else {
setChatList([])
}
}, [conversation_id])
const handleSend = () => {
if (!token || !shareToken) {
return
}
// 添加必需的id和conversation_id属性
const newUserMessage: ChatItem = {
conversation_id,
role: 'user',
content: query?.message || '',
created_at: Date.now()
};
setChatList(prev => [...prev, newUserMessage])
setLoading(true)
setChatLoading(true)
setChatList(prev => [...prev, {
created_at: Date.now(),
role: 'assistant',
content: '',
}])
let currentConversationId: string | null = null
const handleStreamMessage = (data: string) => {
setChatLoading(false)
try {
const lines = data.split('\n');
let currentEvent = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('event:')) {
currentEvent = line.substring(6).trim();
} else if (line.startsWith('data:') && currentEvent === 'message') {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
if (parsed.content) {
setChatList(prev => prev.map((msg, msgIndex) => {
if (msgIndex === prev!.length - 1 && msg.role === 'assistant') {
return { ...msg, content: msg.content + parsed.content };
}
return msg;
}))
}
} else if (line.startsWith('data:') && currentEvent === 'start') {
const jsonData = line.substring(5).trim();
const parsed = JSON.parse(jsonData);
currentConversationId = parsed.conversation_id
} else if (currentEvent === 'end') {
setLoading(false);
if (currentConversationId && currentConversationId !== conversation_id) {
setConversationId(currentConversationId)
getHistory(true)
}
}
}
} catch (e) {
console.error('Parse stream data error:', e);
}
};
sendConversation(token as string, {
message: query?.message || '',
web_search: query?.web_search || false,
memory: query?.memory || false,
stream: true,
conversation_id: conversation_id || null,
}, handleStreamMessage, shareToken)
.finally(() => {
setLoading(false)
})
}
return (
<Flex className="rb:w-full rb:p-[-16px]!">
<div className="rb:w-[345px] rb:h-[100vh] rb:overflow-hidden rb:border-r rb:border-[#EAECEE] rb:p-[12px]">
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-[20px] rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-[8px] rb:py-[10px]"
onClick={() => handleChangeHistory(null)}
>
<div
className="rb:w-[20px] rb:h-[20px] rb:cursor-pointer rb:mr-[8px] rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
></div>
{t('memoryConversation.startANewConversation')}
</div>
{historyList.length > 0 &&
<div
ref={scrollRef}
id="scrollableDiv"
className="rb:overflow-y-auto rb:h-[calc(100vh-255px)]"
>
<InfiniteScroll
dataLength={historyList.length}
next={getHistory}
hasMore={hasMore}
loader={<Skeleton active />}
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
scrollableTarget="scrollableDiv"
>
{Object.entries(groupHistoryList).map(([date, items]) => (
<div key={date} className="rb:mt-[24px] rb:first:mt-0">
<div className="rb:leading-[20px] rb:text-[#5B6167] rb:mb-[8px] rb:pl-[4px] rb:font-regular">{date.replace(/\u200e|\u200f/g, '')}</div>
{items.map(item => (
<div key={item.updated_at} className="rb:mb-[12px]">
<div className={clsx("rb:p-[8px_13px] rb:rounded-[8px] rb:leading-[20px] rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
})}
onClick={() => handleChangeHistory(item.id)}
>
{item.title}
</div>
</div>
))}
</div>
))}
</InfiniteScroll>
</div>
}
<img src={BgImg} className="rb:absolute rb:bottom-0 rb:left-0 rb:w-[345px]" />
</div>
<div className="rb:relative rb:h-[100vh] rb:px-[16px] rb:flex-[1_1_auto]">
<Chat
source="conversation"
empty={
<Empty url={AnalysisEmptyIcon} subTitle={t('memoryConversation.emptyDesc')} />
}
query={query}
data={chatList}
loading={loading}
onChange={setQuery}
onSend={handleSend}
/>
</div>
</Flex>
)
}
export default Conversation

View File

@@ -0,0 +1,13 @@
export interface HistoryItem {
id: string;
app_id: string;
workspace_id: string;
user_id: string | null;
title: string;
summary?: string
is_draft: boolean;
message_count: number;
is_active: boolean;
created_at: number;
updated_at: number;
}

View File

@@ -0,0 +1,195 @@
import { type FC, useRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import type { ConfigForm } from '../types'
interface LineCardProps {
config: ConfigForm
}
const SeriesConfig = {
type: 'line',
smooth: true,
lineStyle: {
width: 3
},
showSymbol: false,
label: {
show: true,
position: 'top'
},
emphasis: {
focus: 'series'
},
}
const Colors = ['#155EEF', '#4DA8FF', '#FFB048']
// 快速遗忘lambda_mem=0.3lambda_time=1offset=0.05 慢速遗忘lambda_mem=1lambda_time=0.3offset=0.2
const LineChart: FC<LineCardProps> = ({ config }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const debounceRef = useRef()
const xAxisData = [1, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60]
const [initialData, setInitialData] = useState([])
const [currentData, setCurrentData] = useState({
...SeriesConfig,
name: `${t('forgettingEngine.currentConfig')}(λ_time=${config?.lambda_mem})`,
data: [],
config: {}
})
const seriesData = [
{
...SeriesConfig,
name: `${t('forgettingEngine.quicklyForget')}(λ_time=0.3)`,
data: [],
config: {lambda_mem: 0.3, lambda_time: 1, offset: 0.05}
},
{
...SeriesConfig,
name: `${t('forgettingEngine.slowForgetting')}(λ_time=1)`,
data: [],
config: {lambda_mem: 1, lambda_time: 0.3, offset: 0.2}
}
]
useEffect(() => {
getInitData()
}, [])
useEffect(() => {
if (config) {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
getCaculateData(config)
}, 500)
}
return () => {
console.log('clearTimeout')
clearTimeout(debounceRef.current)
}
}, [config])
// 快速遗忘lambda_mem=0.3lambda_time=1offset=0.05
// 慢速遗忘lambda_mem=1lambda_time=0.3offset=0.2
const getInitData = () => {
const list = seriesData.map(item => ({
...item,
data: formatData(item.config)
}))
setInitialData(list)
}
const calculateSeriesData = (days: number, data: ConfigForm) => {
const { offset, lambda_time, lambda_mem } = data;
const S = 1
// R = offset + (1 - offset) × e^(-λtime × t / (λmem × S))
return (Number(offset) + (1 - Number(offset)) * Math.exp(-Number(lambda_time) * days / (Number(lambda_mem) * S))).toFixed(4)
}
const formatData = (data: ConfigForm) => {
return xAxisData.map(days => Number(calculateSeriesData(days, data)))
}
const getCaculateData = (data: ConfigForm) => {
if (!data) {
return
}
console.log('getCaculateData', data)
setCurrentData({
...currentData,
config: data,
name: `${t('forgettingEngine.currentConfig')}(λ_time=${data.lambda_time})`,
data: xAxisData.map(days => Number(calculateSeriesData(days, data)))
})
}
return (
<>
{xAxisData.length > 0 && initialData.length > 0 && (
<ReactEcharts
ref={chartRef}
option={{
color: Colors,
tooltip: {
trigger: 'axis',
},
legend: {
data: [currentData.name, ...seriesData.map(item => item.name)],
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
// width: 127,
// overflow: 'break'
},
itemGap: 24,
padding: 0,
itemWidth: 26,
itemHeight: 10,
left: 'center',
bottom: 0,
},
grid: {
left: 4,
right: '2%',
bottom: 60,
top: 32,
containLabel: true
},
xAxis: {
type: 'category',
data: xAxisData,
boundaryGap: false,
axisLine: {
lineStyle: {
color: '#EBEBEB',
},
show: true,
},
axisTick: {
show: true
},
axisLabel: {
color: '#5B6167'
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
lineStyle: {
color: '#EBEBEB',
},
},
},
series: [
currentData,
...initialData || []
]
}}
style={{ height: '450px', width: '100%' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
)}
</>
)
}
export default LineChart

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Row, Col, Form, Slider, Button, Space, message } from 'antd';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import RbCard from '@/components/RbCard/Card';
import strategyImpactSimulator from '@/assets/images/memory/strategyImpactSimulator.svg'
import LineChart from './components/LineChart'
import { getMemoryForgetConfig, updateMemoryForgetConfig } from '@/api/memory'
import type { ConfigForm } from './types'
const configList = [
{
key: 'minimumRetention',
name: 'lambda_time',
range: [0, 1],
type: 'decimal',
},
{
key: 'forgettingRate',
name: 'lambda_mem',
range: [0, 1],
type: 'decimal',
},
{
key: 'offset',
name: 'offset',
type: 'decimal',
}
]
const ForgettingEngine: React.FC = () => {
const { t } = useTranslation();
const params = useParams();
const [configData, setConfigData] = useState<ConfigForm>();
const [form] = Form.useForm<ConfigForm>();
const [messageApi, contextHolder] = message.useMessage();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
useEffect(() => {
getConfigData()
}, [])
const getConfigData = () => {
getMemoryForgetConfig(params.id)
.then((res) => {
const response = res as ConfigForm
const initialValues = {
...response,
lambda_time: Number(response.lambda_time || 0),
lambda_mem: Number(response.lambda_mem || 0),
offset: Number(response.offset || 0),
}
setConfigData(initialValues);
form.setFieldsValue(initialValues);
})
.catch(() => {
console.error('Failed to load data');
})
}
const handleReset = () => {
form.setFieldsValue(configData);
}
const handleSave = () => {
setLoading(true)
updateMemoryForgetConfig({
config_id: params.id,
...values
})
.then(() => {
messageApi.success(t('common.saveSuccess'))
setConfigData({...(values || {})})
})
.finally(() => {
setLoading(false)
})
}
return (
<Row gutter={[16, 16]}>
<Col span={9}>
<RbCard
title={
<div className="rb:flex rb:items-center">
<img src={strategyImpactSimulator} className="rb:w-[20px] rb:h-[20px] rb:mr-[8px]" />
{t('forgettingEngine.forgettingEngineConfigParams')}
</div>
}
className='rb:h-full!'
>
<Form
form={form}
layout="vertical"
initialValues={{
offset: 0,
lambda_time: 0.03,
lambda_mem: 0.03,
}}
>
<Space size={24} direction="vertical" style={{ width: '100%' }}>
{configList.map(config => (
<div key={config.key}>
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px] rb:mb-[8px]">
{t(`forgettingEngine.${config.key}`)}
</div>
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] ">
{t(`forgettingEngine.${config.key}Desc`)}
</div>
<Form.Item
name={config.name}
>
<Slider tooltip={{open: false}} max={config.range?.[1] || 1} min={config.range?.[0] || 0} step={0.01} style={{ margin: '0' }} />
</Form.Item>
<div className="rb:flex rb:text-[12px] rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-[20px] rb:mt-[-26px]">
<Space size={4}>
{config.range && <span>{t(`forgettingEngine.range`)}: {config.range?.join('-')}</span>}
{config.type && <span>{t(`forgettingEngine.type`)}: {config.type}</span>}
</Space>
<>{t('forgettingEngine.CurrentValue')}: {values?.[config.name] || 0}</>
</div>
</div>
))}
<Row gutter={16}>
<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>
</Space>
</Form>
</RbCard>
</Col>
<Col span={15}>
<RbCard
className='rb:h-full!'
>
<LineChart
config={values}
/>
</RbCard>
</Col>
{contextHolder}
</Row>
);
};
export default ForgettingEngine;

View File

@@ -0,0 +1,48 @@
// 标签表单数据类型
export interface TagFormData {
tagName: string;
type: string;
color: string;
description?: string;
applicableScope?: string[];
semanticExpansion?: string;
isActive?: boolean;
// 扩展字段用于区分编辑和新增操作
isEditing?: boolean;
tagId?: string;
}
// 记忆总览数据类型
export interface MemoryOverviewRecord {
id: number;
memoryID: string,
contentSummary: string;
type: string;
createTime: string;
lastCallTime: string;
retentionDegree: string;
status: string;
}
// 定义组件暴露的方法接口
export interface MemoryOverviewFormRef {
handleOpen: (memoryOverview?: MemoryOverviewRecord | null) => void;
}
// 遗忘曲线数据类型
export interface CurveRecord {
memoryID: string;
type: string;
currentRetentionRate: string;
finallyActivated: string;
expectedForgettingTime: string;
reinforcementCount: string;
}
export interface ConfigForm {
statement_granularity?: string;
include_dialogue_context?: boolean;
max_context?: string;
lambda_time: string | number;
lambda_mem: string | number;
offset: string | number;
}

View File

@@ -0,0 +1,23 @@
import { type FC, type ReactNode } from 'react'
import RbCard from '@/components/RbCard/Card'
interface CardProps {
children: ReactNode;
title: string;
headerOperate?: ReactNode;
className?: string;
}
const Card: FC<CardProps> = ({ children, title, headerOperate, className }) => {
return (
<RbCard
headerType="borderless"
title={title}
extra={headerOperate}
className={`rb:h-full! ${className}`}
>
{children}
</RbCard>
)
}
export default Card;

View File

@@ -0,0 +1,176 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Select } from 'antd'
import ReactEcharts from 'echarts-for-react';
import * as echarts from 'echarts';
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
import Card from './Card'
interface LineCardProps {
chartData: Array<Record<string, string | number>>;
limit: number;
onChange: (value: string, type: string) => void;
type: string;
className?: string;
seriesList: string[];
}
const SeriesConfig = {
type: 'line',
stack: 'Total',
smooth: true,
lineStyle: {
width: 3
},
showSymbol: false,
label: {
show: true,
position: 'top'
},
emphasis: {
focus: 'series'
},
data: [220, 302, 181, 234, 210, 290, 150]
}
const Colors = ['#FFB048', '#4DA8FF', '#155EEF']
const LineCard: FC<LineCardProps> = ({ chartData, limit, onChange, type, className, seriesList }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const options = [
{ label: t('dashboard.lastDays', { days: 7 }), value: 7 },
{ label: t('dashboard.lastDays', { days: 30 }), value: 30 },
{ label: t('dashboard.lastDays', { days: 90 }), value: 90 },
{ label: t('dashboard.lastHalfYear'), value: 180 },
{ label: t('dashboard.lastYear'), value: 365 },
]
const getSeries = () => {
const list = seriesList.map((key, index) => {
return {
...SeriesConfig,
name: t(`dashboard.${key}`),
data: chartData.map(vo => vo[key]),
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: Colors[index]
},
{
offset: 1,
color: '#FFFFFF'
}
])
},
}
})
return list
}
const formatSeriesList = () => {
return seriesList.map(key => ({
...SeriesConfig,
name: t(`dashboard.${key}`),
}))
}
return (
<Card
title={t(`dashboard.${type}`)}
headerOperate={
<Select
value={limit}
options={options}
onChange={(value) => onChange(value, type)}
style={{ width: '150px' }}
/>
}
className={`rb:pb-[24px] ${className}`}
>
{chartData && chartData.length > 0 ? (
<ReactEcharts
ref={chartRef}
option={{
color: Colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
},
label: {
show: false
}
},
},
legend: {
data: formatSeriesList(),
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
},
itemGap: 32,
padding: 0,
itemWidth: 26,
itemHeight: 10,
left: 'center'
},
grid: {
left: 4,
right: '3%',
bottom: 0,
containLabel: true
},
xAxis: {
type: 'category',
data: chartData.map(item => formatDateTime(item.created_at, 'DD/MM')),
boundaryGap: false,
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
lineStyle: {
color: '#EBEBEB',
}
},
},
series: getSeries()
}}
style={{ height: '265px', width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
) : <Empty size={120} className="rb:mt-[48px] rb:mb-[81px]" />}
</Card>
)
}
export default LineCard

View File

@@ -0,0 +1,112 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import Card from './Card'
import Loading from '@/components/Empty/Loading'
import Empty from '@/components/Empty'
interface PieCardProps {
chartData: Array<Record<string, string | number>>;
loading: boolean;
}
const Colors = ['#155EEF', '#31E8FF', '#AD88FF', '#FFB048', '#4DA8FF', '#03BDFF']
const PieCard: FC<PieCardProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
return (
<Card
title={t('dashboard.knowledgeBaseTypeDistribution')}
>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-[48px] rb:mb-[81px]" />
: <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: {
right: 20 ,
top: 'middle',
padding: 0,
itemWidth: 12,
itemHeight: 12,
borderRadius: 2,
orient: 'vertical',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['60%', '100%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 4,
width: 200,
height: 200,
left: '10%',
top: 'middle',
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}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
}
</Card>
)
}
export default PieCard

View File

@@ -0,0 +1,49 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom';
import Card from './Card';
import applicationIcon from '@/assets/images/menu/application_active.svg';
import knowledgeIcon from '@/assets/images/menu/knowledge_active.svg';
import memoryConversationIcon from '@/assets/images/menu/memoryConversation_active.svg';
import arrowTopRight from '@/assets/images/home/arrow_top_right.svg';
const quickOperations = [
{ key: 'createNewApplication', url: '/application' },
{ key: 'createNewKnowledge', url: '/knowledge-base' },
{ key: 'memoryConversation', url: '/memory-conversation' },
]
const quickOperationIcons: {[key: string]: string | undefined} = {
createNewApplication: applicationIcon,
createNewKnowledge: knowledgeIcon,
memoryConversation: memoryConversationIcon,
}
const QuickOperation:FC = () => {
const { t } = useTranslation()
const navigate = useNavigate();
const handleJump = (url: string | null) => {
if (url) {
navigate(url)
}
}
return (
<Card
title={t('dashboard.quickOperation')}
>
<div className="rb:grid rb:grid-cols-3 rb:gap-[16px]">
{quickOperations.map(item => (
<div key={item.key} className="rb:rounded-[8px] rb:p-[20px_16px] rb:border-1 rb:border-[#DFE4ED] rb:cursor-pointer rb:hover:border-[#155EEF]" onClick={() => handleJump(item.url)}>
<div className="rb:flex rb:justify-between">
<img className="rb:w-[32px] rb:h-[32px]" src={quickOperationIcons[item.key]} />
<img className="rb:w-[16px] rb:h-[16px]" src={arrowTopRight} />
</div>
<div className="rb:mt-[24px] rb:text-[#212332] rb:text-[16px] rb:leading-[20px] rb:font-medium">{t(`dashboard.${item.key}`)}</div>
<div className="rb:mt-[8px] rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{t(`dashboard.${item.key}Desc`)}</div>
</div>
))}
</div>
</Card>
)
}
export default QuickOperation

View File

@@ -0,0 +1,85 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Skeleton } from 'antd';
import chunkCountIcon from '@/assets/images/home/chunk_count.svg';
import statementsCountIcon from '@/assets/images/home/statements_count.svg';
import tripletCountIcon from '@/assets/images/home/triplet_count.svg';
import temporalCountIcon from '@/assets/images/home/temporal_count.svg';
import activityEmpty from '@/assets/images/home/ActivityEmpty.svg'
import Empty from '@/components/Empty';
import Card from './Card';
import { getRecentActivityStats } from '@/api/memory';
interface Data {
latest_relative: string;
stats: RecentActivities;
}
interface RecentActivities {
"chunk_count": number; // 数据分块
"statements_count": number; // 语句提取
"triplet_entities_count": number; // 实体关系萃取-实体节点
"triplet_relations_count": number; // 实体关系萃取 - 关系连接
"temporal_count": number; // 时间萃取
}
const activityList = [
{ key: 'chunk_count', icon: chunkCountIcon },
{ key: 'statements_count', icon: statementsCountIcon },
{ key: 'triplet_count', icon: tripletCountIcon },
{ key: 'temporal_count', icon: temporalCountIcon },
]
const RecentActivity:FC = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<Data | null>(null);
const [recentActivities, setRecentActivities] = useState<RecentActivities | null>(null);
useEffect(() => {
getRecentActivityList()
}, [])
// 最近活动统计
const getRecentActivityList = () => {
setLoading(true)
getRecentActivityStats().then(res => {
const response = res as Data || {}
setData(response)
setRecentActivities(response.stats as RecentActivities || {})
})
.finally(() => setLoading(false))
}
return (
<Card
title={t('dashboard.recentMemoryActivities')}
>
{loading
? <Skeleton />
: !recentActivities || Object.keys(recentActivities).length === 0
? <Empty url={activityEmpty} subTitle={t('dashboard.activityEmpty')} size={120} className="rb:mt-[45px] rb:mb-[81px]" />
: activityList.map((item, index) => (
<div key={item.key} className={clsx("rb:flex rb:justify-between rb:items-center rb:not-italic", {
'rb:mt-[24px]': index !== 0
})}>
<div className="rb:flex rb:items-center rb:text-[#060419] rb:text-[16px] rb:font-medium">
<img className="rb:w-[40px] rb:h-[40px] rb:mr-[16px]" src={item.icon} />
<div>
{t(`dashboard.${item.key}`)}
<div className="rb:text-[#5B6167] rb:text-[14px] rb:font-normal">
{item.key === 'triplet_count'
? t(`dashboard.${item.key}_desc`, { entities_count: recentActivities.triplet_entities_count, relations_count: recentActivities.triplet_relations_count })
: t(`dashboard.${item.key}_desc`, { count: recentActivities[item.key as keyof RecentActivities] })
}
</div>
</div>
</div>
<div className="rb:text-[#5F6266] rb:text-right rb:whitespace-nowrap">{data?.latest_relative || ''}</div>
</div>
))
}
</Card>
)
}
export default RecentActivity

View File

@@ -0,0 +1,50 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Skeleton } from 'antd';
import tagEmpty from '@/assets/images/home/tagEmpty.svg'
import Empty from '@/components/Empty';
import Card from './Card';
import { getHotMemoryTags } from '@/api/memory';
const TagList:FC = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState<boolean>(false)
const [tagList, setTagList] = useState<Array<{ name: string; frequency: number }>>([]);
useEffect(() => {
getTagList()
}, [])
// 热门记忆标签
const getTagList = () => {
setLoading(true)
getHotMemoryTags().then(res => {
setTagList(Array.isArray(res) ? res : [])
}).finally(() => setLoading(false))
}
return (
<Card
title={t('dashboard.popularMemoryTags')}
>
{loading
? <Skeleton />
: !tagList || tagList.length === 0
? <Empty url={tagEmpty} title={t('dashboard.activityEmpty')} size={120} className="rb:mt-[36px] rb:mb-[81px]" />
: <div className="rb:gap-[12px] rb:flex rb:flex-wrap">
{tagList.map((item, index) => (
<div
key={item.name}
className={clsx("rb:pt-[6px] rb:pb-[6px] rb:pr-[23px] rb:pl-[20px] rb:border-1 rb:leading-[20px] rb:bg-white rb:rounded-[17px]", {
'rb:border-[rgba(21,94,239,0.4)] rb:text-[#155EEF]': index % 3 === 0,
'rb:border-[rgba(255,138,76,0.4)] rb:text-[#FF5D34]': index % 3 === 1,
'rb:border-[rgba(54,159,33,0.4)] rb:text-[#369F21]': index % 3 === 2,
})}
>{item.name} {item.frequency}</div>
))}
</div>
}
</Card>
)
}
export default TagList

View File

@@ -0,0 +1,97 @@
.card {
border-radius: 12px;
border: 1px solid #DFE4ED;
padding: 0;
}
.header {
padding: 20px;
line-height: 44px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 16px;
color: #5B6167;
font-style: normal;
display: flex;
align-items: center;
border-bottom: 1px solid #DFE4ED;
}
.avatar {
width: 44px;
height: 44px;
background: #FFFFFF;
box-shadow: 0px 2px 6px 0px rgba(33, 35, 50, 0.1);
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.avatar img {
width: 24px;
height: 24px;
}
.content {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-family: Gilroy, Gilroy;
font-weight: 800;
font-size: 28px;
color: #212332;
text-align: left;
font-style: normal;
}
.content-right {
text-align: right;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 12px;
color: #5F6266;
line-height: 16px;
font-style: normal;
row-gap: 4px;
}
.trend {
font-family: PingFangSC, PingFang SC;
font-weight: 500;
font-size: 14px;
line-height: 16px;
font-style: normal;
padding-left: 15px;
position: relative;
margin-bottom: 4px;
display: inline-block;
}
.trend::before {
width: 14px;
height: 14px;
content: '';
position: absolute;
left: 0;
top: 1px;
background-repeat: no-repeat;
background-size: contain;
}
.trend.up {
color: #369F21;
}
.trend.up::before {
background-image: url('@/assets/images/home/arrow_up_success.svg');
}
.trend.down {
color: #FF5D34;
}
.trend.down::before {
background-image: url('@/assets/images/home/arrow_down.png');
}
.trend-desc {
font-family: PingFangSC, PingFang SC;
font-weight: 500;
font-size: 14px;
color: #155EEF;
line-height: 16px;
text-align: left;
font-style: normal;
}

View File

@@ -0,0 +1,81 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import totalMemoryCapacity from '@/assets/images/home/totalMemoryCapacity.svg';
import userMemory from '@/assets/images/home/userMemory.svg';
import knowledgeBaseCount from '@/assets/images/home/knowledgeBaseCount.svg';
import apiCallCount from '@/assets/images/home/apiCallCount.svg';
import styles from './index.module.css'
import clsx from 'clsx';
import type { DashboardData } from '../../index'
const list = [
{
key: 'totalMemoryCapacity',
icon: totalMemoryCapacity,
// value: '45,678',
// trendValue: '12.5%',
// trend: 'up',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient(180deg, #E6EFFE 0%, #F9FDFF 100%)',
},
{
key: 'application',
icon: userMemory,
// value: '32,145',
// trendValue: '12.5%',
// trend: 'down',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient( 180deg, #F1FBF5 0%, #F9FDFF 100%)',
},
{
key: 'knowledgeBaseCount',
icon: knowledgeBaseCount,
// value: '13,533',
// trendValue: '15.7%',
// trend: 'up',
// trendDesc: 'thisWeek',
background: 'linear-gradient( 180deg, #E6F5FE 0%, #FBFDFF 100%)',
},
{
key: 'apiCallCount',
icon: apiCallCount,
// value: '856.2k',
// trendValue: '23.1%',
// trend: 'up',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient( 180deg, #F8F6F5 0%, #FAFDFF 100%)',
},
]
const TopCardList: FC<{data?: DashboardData}> = ({ data }) => {
const { t } = useTranslation()
return (
<div className="rb:grid rb:grid-cols-4 rb:gap-[16px]">
{list.map((item) => {
return (
<div
key={item.key}
className={styles.card}
style={{
background: item.background,
}}
>
<div className={styles.header}>
<div className={styles.avatar}><img src={item.icon} /></div>
<div className={styles.headerTitle}>{t(`dashboard.${item.key}`)}</div>
</div>
<div className={styles.content}>
{data?.[item.key] || item.value || 0}
<div className={styles.contentRight}>
{item.trendValue && <div className={clsx(styles.trend, styles[item.trend])}>{item.trendValue}</div>}
{item.trendDesc && <div>{t(`dashboard.${item.trendDesc}`)}</div>}
</div>
</div>
</div>
)
})}
</div>
)
}
export default TopCardList

View File

@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Row, Col, Space } from 'antd';
import TopCardList from './components/TopCardList'
import LineCard from './components/LineCard'
import PieCard from './components/PieCard'
import { getDashboardData, getMemoryIncrement, getKbTypes } from '@/api/memory';
import RecentActivity from './components/RecentActivity'
import TagList from './components/TagList'
import QuickOperation from './components/QuickOperation'
export interface DashboardData {
totalMemoryCapacity?: number;
application?: number;
knowledgeBaseCount?: number;
apiCallCount?: number;
}
const Home = () => {
const [dashboardData, setDashboardData] = useState<DashboardData>({});
const [loading, setLoading] = useState({
knowledgeTypeDistribution: true,
});
const [knowledgeTypeDistribution, setKnowledgeTypeDistribution] = useState<Array<{ name: string; value: number }>>([]);
const [memoryIncrement, setMemoryIncrement] = useState<Array<{ updated_at: string; total_num: number; }>>([]);
const [limit, setLimit] = useState(7);
// 模拟API获取数据
useEffect(() => {
getData()
getKnowledgeTypeDistribution()
}, []);
// 记忆总量 / 应用数量 / 知识库数量 / API调用次数
const getData = () => {
getDashboardData().then(res => {
const response = res as {
storage_type: 'rag' | 'neo4j',
neo4j_data?: {
total_memory?: number;
total_app?: number;
total_knowledge?: number;
total_api_call?: number;
};
rag_data?: {
total_memory?: number;
total_app?: number;
total_knowledge?: number;
total_api_call?: number;
}
}
const { storage_type = 'neo4j' } = response || {}
const responseData = response[storage_type + '_data'] || {}
setDashboardData({
totalMemoryCapacity: responseData.total_memory || 0,
application: responseData.total_app || 0,
knowledgeBaseCount: responseData.total_knowledge || 0,
apiCallCount: responseData.total_api_call || 0
})
})
}
// 知识库类型分布 / 知识库数量
const getKnowledgeTypeDistribution = () => {
setLoading({
...loading,
knowledgeTypeDistribution: true,
})
getKbTypes().then(res => {
const response = res as Record<string, number>
const list: Array<{ name: string; value: number }> = []
Object.entries(response).map(([type, count]) => {
if (count > 0 && type !== 'total') {
list.push({
name: type,
value: count
})
}
return null
})
setKnowledgeTypeDistribution(list)
})
.finally(() => {
setLoading({
...loading,
knowledgeTypeDistribution: false,
})
})
}
// 记忆增长趋势
const getMemoryIncrementData = () => {
getMemoryIncrement(limit).then(res => {
const response = res as { updated_at: string; total_num: number; }[]
setMemoryIncrement(response || [])
})
}
useEffect(() => {
getMemoryIncrementData()
}, [limit])
const handleRangeChange = (value: string, type: string) => {
switch (type) {
case 'memoryGrowthTrend':
setLimit(Number(value))
break
}
}
return (
<div className="rb:pb-[24px]">
{/* 统计卡片 */}
<TopCardList data={dashboardData} />
<Row className="rb:mt-[16px]" gutter={16}>
{/* 记忆增长趋势 */}
<Col span={12}>
<LineCard
chartData={memoryIncrement}
limit={limit}
onChange={handleRangeChange}
type="memoryGrowthTrend"
seriesList={['total_num']}
/>
</Col>
{/* 知识库类型分布 */}
<Col span={12}>
<PieCard
loading={loading.knowledgeTypeDistribution}
chartData={knowledgeTypeDistribution}
/>
</Col>
</Row>
<Row className="rb:mt-[16px]" gutter={16}>
<Col span={12}>
{/* 最近记忆活动 */}
<RecentActivity />
</Col>
<Col span={12}>
{/* 热门记忆标签 */}
<TagList />
</Col>
</Row>
<Row className="rb:mt-[16px]" gutter={16}>
<Col span={24}>
{/* 快速操作 */}
<QuickOperation />
</Col>
</Row>
</div>
);
}
export default Home

View File

@@ -0,0 +1,32 @@
.form {
margin: 0 24px 32px 24px !important;
}
.form :global(.ant-form-item-label) {
margin-left: 4px;
}
.form :global(.ant-form-item-extra) {
margin-top: 8px;
color: #999999;
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.form :global(.ant-form-item-explain-error) {
margin-top: 8px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.form :global(.ant-progress-steps-outer) {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.form :global(.ant-progress-steps-item) {
width: 100% !important;
border-radius: 5px;
background-color: #EFF1F6;
}
.form :global(.ant-progress .ant-progress-steps-item-active) {
background-color: #1677ff;
}

View File

@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Input, Form, Progress, App } from 'antd';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { useUser, type LoginInfo } from '@/store/user';
import type { FormProps } from 'antd';
import { login } from '@/api/user'
import inviteBg from '@/assets/images/login/inviteBg.png'
import checkBg from '@/assets/images/login/checkBg.png'
import type { LoginForm, ValidateToken } from './types';
import { validateInviteToken } from '@/api/member'
import RbAlert from '@/components/RbAlert'
import styles from './index.module.css'
const Extra = ({ children }: { children: React.ReactNode }) => (
<div className="rb:flex rb:items-start">
<ExclamationCircleFilled className="rb:mr-[4px] rb:mt-[3px]" />
{children}
</div>
)
const InviteRegister: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { token } = useParams();
const { clearUserInfo, updateLoginInfo } = useUser();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<LoginForm>();
const { message } = App.useApp();
const [passwordStrength, setPasswordStrength] = useState<'weak' | 'medium' | 'strong' | null>(null);
const values = Form.useWatch<LoginForm>([], form);
useEffect(() => {
clearUserInfo();
getInitalData()
}, []);
const getInitalData = () => {
if (!token) {
message.warning(t('user.inviteLinkInvalid'))
return
}
validateInviteToken(token).then((res) => {
const response = res as ValidateToken
form.setFieldsValue({
email: response.email,
})
})
}
// 密码强度校验函数
const validatePasswordStrength = (password: string): { strength: 'weak' | 'medium' | 'strong', error: string } => {
let strength: 'weak' | 'medium' | 'strong' = 'weak';
let score = 0;
let error = '';
// 密码长度检查
if (password.length < 8) {
error = t('login.lengthDesc');
return { strength, error };
}
score += 1;
// 包含数字
if (/\d/.test(password)) score += 1;
// 包含小写字母
if (/[a-z]/.test(password)) score += 1;
// 包含大写字母
if (/[A-Z]/.test(password)) score += 1;
// 包含特殊字符
if (/[^A-Za-z0-9]/.test(password)) score += 1;
// 判断强度
if (score >= 4) {
strength = 'strong';
} else if (score >= 3) {
strength = 'medium';
}
// 根据强度返回提示
if (strength === 'weak' && score >= 1) {
error = t('login.weakDesc');
} else if (strength === 'medium') {
error = t('login.mediumDesc');
}
return { strength, error };
};
// 监听密码变化,更新强度
const handlePasswordChange = (value: string) => {
if (!value) {
setPasswordStrength(null);
return;
}
const { strength } = validatePasswordStrength(value);
setPasswordStrength(strength);
};
// 密码一致性校验
const validateConfirmPassword = (_: unknown, value: string) => {
const password = values.password;
if (!value) {
return Promise.reject(new Error('请确认密码'));
}
if (value !== password) {
return Promise.reject(new Error('两次输入的密码不一致'));
}
return Promise.resolve();
};
// 处理注册提交
const handleRegister: FormProps<LoginForm>['onFinish'] = async (values) => {
setLoading(true);
login({
username: values.username,
email: values.email,
password: values.password,
invite: token
}).then((res) => {
const response = res as LoginInfo;
updateLoginInfo(response);
navigate('/');
}).finally(() => {
setLoading(false);
});
};
return (
<div className="rb:w-screen rb:h-screen rb:flex rb:items-center rb:justify-center">
<img src={inviteBg} className="rb:w-screen rb:h-screen rb:fixed rb:top-0 rb:left-0 rb:z-[0]" />
<div className="rb:relative rb:z-[1] rb:w-[480px] rb:max-h-full rb:overflow-y-auto rb:bg-[#FFFFFF] rb:rounded-[12px] rb:shadow-[0px_2px_10px_0px_rgba(11,49,124,0.2)]">
<div className="rb:bg-[url('@/assets/images/login/inviteForm.png')] rb:bg-cover rb:bg-no-repeat rb:text-[24px] rb:font-bold rb:leading-[32px] rb:p-[28px_24px]">
{t('login.welcomeTeam')}
<div className="rb:text-[#5B6167] rb:text-[12px] rb:font-regular rb:leading-[16px] rb:mt-[10px]">{t('login.welcomeTeamSubTitle')}</div>
</div>
<Form
form={form}
onFinish={handleRegister}
layout="vertical"
className={styles.form}
>
<RbAlert icon={<img src={checkBg} className="rb:w-[24px] rb:h-[24px]" />} className="rb:mb-[24px]">
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px]">
{t('login.invitationVerified')}
<div className="rb:text-[12px] rb:font-regular rb:leading-[16px] rb:mt-[4px]">{t('login.account')}: {values?.email || '-'}</div>
</div>
</RbAlert>
<Form.Item
name="email"
label={t('login.emailAccount')}
extra={<Extra>{t('login.emailAccountDesc')}</Extra>}
>
<Input disabled />
</Form.Item>
<Form.Item
name="password"
label={t('login.setPassword')}
extra={
<div>
<div className="rb:mb-[12px]">
<Progress
percent={passwordStrength === 'weak' ? 33 : passwordStrength === 'medium' ? 66 : passwordStrength === 'strong' ? 100 : 0}
steps={3}
showInfo={false}
style={{width: '100%'}}
/>
<div className="rb:font-medium rb:mt-[8px]">
{t('login.passwordStrength')}:
{passwordStrength
? <span className="rb:text-[#155EEF]">{t(`login.${passwordStrength}`)}</span>
: <span className="rb:font-regular">{t('login.noSet')}</span>
}
</div>
</div>
<Extra>{t('login.setPasswordDesc')}</Extra>
</div>
}
rules={[
{ required: true, message: t('login.setPasswordPlaceholder') },
{
validator: (_, value) => {
if (!value) {
return Promise.reject(new Error(t('login.lengthDesc')));
}
const { error } = validatePasswordStrength(value);
if (error && value.length >= 8) {
return Promise.resolve(); // 强度提示但不阻止提交
} else if (error) {
return Promise.reject(new Error(error));
}
return Promise.resolve();
},
validateTrigger: ['blur']
}
]}
>
<Input.Password
placeholder={t('login.setPasswordPlaceholder')}
onChange={(e) => handlePasswordChange(e.target.value)}
onBlur={(e) => handlePasswordChange(e.target.value)}
/>
</Form.Item>
<Form.Item
name="confirmPassword"
label={t('login.confirmPassword')}
rules={[
{ required: true, message: t('login.confirmPasswordPlaceholder') },
{ validator: validateConfirmPassword }
]}
>
<Input.Password
placeholder={t('login.confirmPasswordPlaceholder')}
/>
</Form.Item>
<Form.Item
name="username"
label={<>{t('login.name')}<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[20px]"> {t('login.nameSubTitle')}</span></>}
>
<Input placeholder={t('login.namePlaceholder')} />
</Form.Item>
<Button
type="primary"
block
loading={loading}
htmlType="submit"
className="rb:h-[40px]! rb:rounded-[8px]!"
>
{t('login.register')}
</Button>
</Form>
</div>
</div>
);
};
export default InviteRegister;

View File

@@ -0,0 +1,14 @@
export interface LoginForm {
email: string;
password: string;
confirmPassword: string;
username: string;
}
export interface ValidateToken {
workspace_name: string;
workspace_id: string;
email: string;
role: string;
is_expired: boolean;
is_valid: boolean;
}

View File

@@ -0,0 +1,596 @@
import { useMemo,useRef, useState, useEffect } from 'react';
import { Button, Flex, Radio, Steps, Modal, Input, Spin,message} from 'antd';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import Table, { type TableRef } from '@/components/Table'
import type { AnyObject } from 'antd/es/_util/type';
import type { UploadFileResponse,KnowledgeBaseDocumentData } from '../types';
import type { ColumnsType } from 'antd/es/table';
import UploadFiles from '@/components/Upload/UploadFiles';
import type { UploadRequestOption } from 'rc-upload/lib/interface';
import { uploadFile, getDocumentList, previewDocumentChunk, parseDocument, updateDocument, deleteDocument } from '../service';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import { NoData } from '../components/noData';
import noDataIcon from '@/assets/images/knowledgeBase/noData.png';
import SliderInput from '@/components/SliderInput';
import DelimiterSelector from '../components/DelimiterSelector';
const { confirm } = Modal
const { TextArea } = Input;
import styles from '../index.module.css';
const style: React.CSSProperties = {
display: 'flex',
gap: 16,
};
const radioWrapperBaseStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
columnGap: 14, // 点与文字更宽的间距
width: '100%',
border: '1px solid #E5E5E5',
borderRadius: 8,
padding: 16,
};
const getActiveRadioStyle = (active: boolean): React.CSSProperties => ({
...radioWrapperBaseStyle,
border: active ? '1px solid #1677ff' : radioWrapperBaseStyle.border,
});
type SourceType = 'local' | 'link' | 'text';
type ProcessingMethod = 'directBlock' | 'qaExtract';
type ParameterSettings = 'defaultSettings' | 'customSettings';
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
type StepKey = typeof stepKeys[number];
const stepIndexMap: Record<StepKey, number> = {
selectFile: 0,
parameterSettings: 1,
dataPreview: 2,
confirmUpload: 3,
};
interface CreateDatasetLocationState {
source?: SourceType;
knowledgeBaseId?: string;
parentId?: string;
startStep?: StepKey;
fileId?: string;
fileIds?: string[];
}
const CreateDataset = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { knowledgeBaseId: routeKnowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const location = useLocation();
const locationState = (location.state ?? {}) as CreateDatasetLocationState;
const source = (locationState.source ?? 'local') as SourceType;
const knowledgeBaseId = locationState.knowledgeBaseId || routeKnowledgeBaseId;
const parentId = locationState.parentId;
const initialStepKey = locationState.startStep ?? 'selectFile';
const initialFileIds = locationState.fileIds ?? (locationState.fileId ? [locationState.fileId] : []);
const [current, setCurrent] = useState<number>(stepIndexMap[initialStepKey]);
const tableRef = useRef<TableRef>(null);
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
const [chunkData, setChunkData] = useState<any[]>([]);
const [total, setTotal] = useState<number>(0);
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
const [curSelectedFileId, setCurSelectedFileId] = useState<number>(-1);
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const [pollingLoading, setPollingLoading] = useState<boolean>(false);
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
const [blockSize, setBlockSize] = useState<number>(130);
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
const [messageApi, contextHolder] = message.useMessage();
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg']
const steps = useMemo(
() => [
{ title: t('knowledgeBase.selectFile') },
{ title: t('knowledgeBase.parameterSettings') },
// { title: t('knowledgeBase.dataPreview') }, // 暂时隐藏第三步
{ title: t('knowledgeBase.confirmUpload') },
],
[t],
);
const handleNext = () => {
// 暂时隐藏第三步调整步骤索引0->1->2 对应 选择文件->参数设置->确认上传)
let nextStep = current + 1;
if(nextStep === 1) {
// 检查是否有文件已上传
if (rechunkFileIds.length === 0) {
// 如果没有文件,提示用户先上传文件
Modal.warning({
title: t('common.warning') || '提示',
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
});
return; // 不进入下一步
}
}
// 从参数设置进入确认上传时的处理
if(current === 1 && nextStep === 2) {
// handlePreview(data[0],0)
if(parameterSettings === 'customSettings'){
rechunkFileIds.map((id) => {
const params = {
parser_config: {
layout_recognize:'DeepDOC',
delimiter: delimiter,
chunk_token_num: blockSize,
}
}
updateDocument(id, params)
})
}
// 立即执行一次,加载文档列表用于预览(不自动返回)
pollDocumentStatus(false);
}
// 限制最大步骤为 2确认上传
setCurrent(Math.min(nextStep, 2));
};
const handlePrev = () => setCurrent((c) => Math.max(c - 1, 0));
// 开始上传:触发文档解析并启动轮询
const handleStartUpload = () => {
if (rechunkFileIds.length === 0) {
Modal.warning({
title: t('common.warning') || '提示',
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
});
return;
}
// 显示确认弹框
confirm({
title: t('knowledgeBase.startUploadConfirmTitle') || '开始处理文档',
content: t('knowledgeBase.startUploadConfirmContent') || '文档处理将在后台进行,您可以选择立即返回列表页或停留在此页面查看处理进度。',
okText: t('knowledgeBase.returnToList') || '返回列表页',
cancelText: t('knowledgeBase.stayOnPage') || '停留在此页',
onOk: () => {
// 用户选择返回列表页
startProcessing(true);
},
onCancel: () => {
// 用户选择停留在当前页
startProcessing(false);
},
});
};
// 实际开始处理的函数
const startProcessing = (autoReturnToList: boolean) => {
// 触发文档解析
rechunkFileIds.map((id) => {
parseDocument(id);
});
// 开启 loading
setPollingLoading(true);
if (autoReturnToList) {
// 用户选择立即返回,直接跳转
console.log('用户选择立即返回列表页');
handleBack();
} else {
// 用户选择停留,启动轮询查看进度
console.log('用户选择停留查看进度');
// 立即执行一次轮询(启用自动返回)
pollDocumentStatus(true);
// 然后每3秒执行一次启用自动返回
pollingTimerRef.current = setInterval(() => {
pollDocumentStatus(true);
}, 5000);
}
};
const handleDelete = (record: AnyObject) => {
confirm({
title: t('common.deleteWarning'),
content: t('common.deleteWarningContent', { content: record.name }),
onOk: async () => {
// TODO: 实现删除逻辑
const response = await deleteDocument(record.id);
// 删除成功,刷新列表
// messageApi.success(t('common.deleteSuccess'));
messageApi.success(t('common.deleteSuccess'));
tableRef.current?.loadData();
},
onCancel: () => {
console.log('取消删除');
},
});
}
// 表格列配置
const columns: ColumnsType = [
{
title: t('knowledgeBase.name'),
dataIndex: 'file_name',
key: 'file_name'
},
{
title: t('knowledgeBase.status'),
dataIndex: 'progress',
key: 'progress',
render: (value: number) => {
return (
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
<span className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full" style={{ backgroundColor: value === 1 ? '#369F21' : '#FF8A4C' }}></span>
<span>{value === 1 ? 'Completed' : 'Processing'}</span>
</span>
);
}
},
{
title: t('common.operation'),
key: 'action',
render: (_, record) => (
<Button type='text' danger onClick={() => handleDelete(record)}>{t('common.delete')}</Button>
),
},
];
// 上传文件
const handleUpload = (options: UploadRequestOption) => {
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
const formData = new FormData();
formData.append(filename, file as File);
if (knowledgeBaseId) {
formData.append('kb_id', knowledgeBaseId);
}
if (parentId) {
formData.append('parent_id', parentId);
}
uploadFile(formData, {
kb_id: knowledgeBaseId,
parent_id: parentId,
onUploadProgress: (event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.({ percent }, file);
},
})
.then((res: UploadFileResponse) => {
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
setRechunkFileIds((prev) => {
if (prev.includes(res.id)) return prev;
const next = [...prev, res.id];
return next;
});
}
})
.catch((error) => {
onError?.(error as Error);
});
};
// 点击文件 预览分块
const handlePreview = async(item: KnowledgeBaseDocumentData, index: number) => {
setCurSelectedFileId(index);
setPreviewLoading(true);
try{
const res = await previewDocumentChunk(knowledgeBaseId ?? '', item.id ?? '');
setChunkData(res.items || []);
setTotal(res.page.total || 0);
console.log('res', res);
}catch(error) {
console.log('error', error);
} finally {
setPreviewLoading(false);
}
}
// 轮询检查文档处理状态
// autoReturn: 是否在所有文档完成时自动返回列表页
const pollDocumentStatus = (autoReturn: boolean = false) => {
if (!knowledgeBaseId || !parentId || rechunkFileIds.length === 0) {
return;
}
// 刷新 Table 组件的数据(仅在 confirmUpload 步骤)
if (current === 2) {
tableRef.current?.loadData();
}
// 同时获取文档列表检查是否全部完成
getDocumentList({
kb_id: knowledgeBaseId,
parent_id: parentId,
document_ids: rechunkFileIds.join(','),
})
.then((res: any) => {
const documents = res.items || [];
setData(documents);
// 检查是否所有文档的 progress 都为 1
const allCompleted = documents.every((doc: KnowledgeBaseDocumentData) => doc.progress === 1);
console.log('轮询状态:', documents.map((d: KnowledgeBaseDocumentData) => ({ name: d.file_name, progress: d.progress })));
// 只有在 autoReturn 为 true 且所有文档完成时才自动返回
if (allCompleted && autoReturn) {
// 所有文档处理完成,清除定时器和 loading
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
setPollingLoading(false);
// 延迟 2 秒后跳转,让用户看到完成状态
console.log('所有文档处理完成2秒后返回列表页');
setTimeout(() => {
handleBack();
}, 2000);
}
})
.catch((error) => {
console.error('轮询文档状态失败:', error);
setPollingLoading(false);
});
};
const handleBack = () => {
if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/private`, {
state: {
refresh: true,
timestamp: Date.now(), // 添加时间戳确保每次都是新的 state
},
});
} else {
console.warn('缺少路由参数,无法返回');
}
};
const handleChange = (value: number | null) =>{
if (value !== null) {
setBlockSize(value);
}
}
// 当从其他页面跳转过来且带有 fileIds 时,加载对应的文档数据
useEffect(() => {
if (initialFileIds.length > 0 && initialStepKey !== 'selectFile' && knowledgeBaseId && parentId) {
// 加载文档列表数据
getDocumentList({
kb_id: knowledgeBaseId,
parent_id: parentId,
document_ids: initialFileIds.join(','),
})
.then((res: any) => {
const documents = res.items || [];
setData(documents);
})
.catch((error) => {
console.error('加载文档列表失败:', error);
});
}
}, []);
// 清理函数:组件卸载时清除定时器
useEffect(() => {
return () => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
setPollingLoading(false);
};
}, []);
return (
<>
{contextHolder}
<div className='rb:p-6 rb:pt-2 rb:h-full'>
{/* <Typography.Title level={4} className='rb:!m-0 rb:!mb-4'>
{t('knowledgeBase.createA') + ' ' + t('knowledgeBase.dataset')}
</Typography.Title> */}
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
<div className='rb:px-24 rb:py-5 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED]'>
<Steps current={current} items={steps} />
</div>
{current === 0 && (
<div className='rb:flex rb:w-full rb:mt-10'>
{source && source === 'local' && (
<UploadFiles isCanDrag={true} fileSize={50} multiple={true} maxCount={99} fileType={fileType} customRequest={handleUpload} />
)}
{source && source === 'link' && (
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
{t('knowledgeBase.webLink')}
</div>
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
<div className='rb:text-sm rb:text-gray-500 rb:mt-3 rb:max-w-[558px]'>
{t('knowledgeBase.webLinkDesc')}
</div>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
{t('knowledgeBase.selectorTutorial')}
</div>
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
</div>
)}
{source && source === 'text' && (
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
{t('knowledgeBase.customText')}
</div>
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
{t('knowledgeBase.customContent')}
</div>
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
</div>
)}
</div>
)}
{current === 1 && (
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
{rechunkFileIds.length > 0 && (
<div className='rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded rb:px-3 rb:py-2 rb:mb-4 rb:text-xs rb:text-gray-600 rb:flex rb:flex-wrap rb:gap-2'>
<span className='rb:text-gray-700 rb:font-medium'>{t('knowledgeBase.rechunking')}:</span>
{rechunkFileIds.map((id) => (
<span key={id} className='rb:px-2 rb:py-0.5 rb:bg-white rb:border rb:border-[#DFE4ED] rb:rounded'>{id}</span>
))}
</div>
)}
<div className='rb:text-base rb:font-medium rb:text-gray-800'>
{t('knowledgeBase.dataProcessingSettings')}
</div>
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
{t('knowledgeBase.processingMethod')}
</div>
<Radio.Group
value={processingMethod}
onChange={(e) => setProcessingMethod(e.target.value)}
style={style}
>
<Radio value='directBlock' style={getActiveRadioStyle(processingMethod === 'directBlock')}>
<Flex gap='small' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
{t('knowledgeBase.directBlock')}
</span>
</Flex>
</Radio>
<Radio value='qaExtract' style={getActiveRadioStyle(processingMethod === 'qaExtract')}>
<Flex gap='small' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
{t('knowledgeBase.qaExtract')}
</span>
</Flex>
</Radio>
</Radio.Group>
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
{t('knowledgeBase.parameterSettings')}
</div>
<Radio.Group
value={parameterSettings}
onChange={(e) => setParameterSettings(e.target.value)}
style={style}
>
<Radio value='defaultSettings' style={getActiveRadioStyle(parameterSettings === 'defaultSettings')}>
<Flex gap='small' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
{t('knowledgeBase.default')}
</span>
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.defaultSettings')}</span>
</Flex>
</Radio>
<Radio value='customSettings' style={getActiveRadioStyle(parameterSettings === 'customSettings')}>
<Flex gap='small' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
{t('knowledgeBase.customize')}
</span>
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.customSettings')}</span>
</Flex>
</Radio>
</Radio.Group>
{parameterSettings === 'customSettings' && (
<div className='rb:flex rb:flex-col rb:mt-5'>
<div className='rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
{t('knowledgeBase.delimiter')}
</div>
<DelimiterSelector value={delimiter} onChange={setDelimiter} className='rb:mb-5'/>
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
</div>
)}
</div>
)}
{/* 暂时隐藏第三步:数据预览 */}
{/* {current === stepIndexMap.dataPreview && (
<div className='rb:grid rb:grid-cols-2 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:h-[calc(100%-160px)] rb:bg-[#FBFDFF] rb:mt-4'>
<div className='rb:border-r rb:h-full rb:overflow-hidden rb:border-[#DFE4ED]'>
<div className='rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
{t('knowledgeBase.fileList')}
</div>
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
{data.map((item, index) => (
<div key={index} className={`rb:h-11 rb:w-full rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3 rb:hover:text-[#155EEF] rb:cursor-pointer ${curSelectedFileId === index ? styles.textBg + ' ' + styles.active : ''}`}
onClick={() => handlePreview(item, index)}>
{item.file_name}
</div>
))
}
</div>
</div>
<div className='rb:h-full rb:overflow-hidden'>
<div className='rb:flex rb:items-center rb:justify-between rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
{t('knowledgeBase.dataPreview')}
<span className='rb:text-sm rb:text-gray-500'>{t('knowledgeBase.maxPreviewChunks', {count: total, max: chunkData.length})}</span>
</div>
<Spin spinning={previewLoading}>
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
{chunkData.length > 0 ? (
chunkData.map((item, index) => (
<div key={index} className='rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3'
dangerouslySetInnerHTML={{ __html: item.page_content }}
/>
))
) : (
<NoData title={t('knowledgeBase.noChunksToPreview')}
subTitle={t('knowledgeBase.clickToPreview')}
image={noDataIcon}
/>
)}
</div>
</Spin>
</div>
</div>
)} */}
{current === 2 && (
<Spin spinning={pollingLoading} tip={t('knowledgeBase.processingDocuments') || '正在处理文档...'}>
<div className='rb:text-sm rb:text-gray-500 rb:mt-4 rb:h-[calc(100%-160px)] rb:overflow-y-auto'>
<Table
ref={tableRef}
apiUrl={`/documents/${knowledgeBaseId}/${parentId}/documents`}
apiParams={{
document_ids: rechunkFileIds.join(','),
}}
columns={columns}
rowKey="id"
/>
</div>
</Spin>
)}
<div className={`rb:flex rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-40 rb:mt-10' : ''}`}>
{current !== 0 && (
<Button onClick={handlePrev} disabled={current === 0 || pollingLoading}>
{t('common.previous') || 'Prev'}
</Button>
)}
<Button
type='primary'
onClick={current === 2 ? handleStartUpload : handleNext}
disabled={pollingLoading}
loading={pollingLoading}
>
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
</Button>
</div>
</div>
</>);
};
export default CreateDataset;

View File

@@ -0,0 +1,384 @@
/*
* @Description: 文档详情
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-15 16:13:47
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-29 19:46:46
*/
import { useEffect, useState, useRef, type FC } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button, Spin, message, Switch } from 'antd';
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '../service';
import type { KnowledgeBaseDocumentData, RecallTestData } from '../types';
import { formatDateTime } from '@/utils/format';
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
import RecallTestResult from '../components/RecallTestResult';
import SearchInput from '@/components/SearchInput';
import DocumentPreview from '@/components/DocumentPreview';
import InsertModal, { type InsertModalRef } from '../components/InsertModal';
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
const imagePath = 'https://devapi.mem.redbearai.com'
const DocumentDetails: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const location = useLocation();
const { documentId, parentId: locationParentId } = location.state as { documentId: string; parentId?: string };
const [loading, setLoading] = useState(false);
const [document, setDocument] = useState<KnowledgeBaseDocumentData | null>(null);
const [chunkList, setChunkList] = useState<RecallTestData[]>([]);
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [chunkLoading, setChunkLoading] = useState(false);
const [keywords, setKeywords] = useState('');
const [fileUrl, setFileUrl] = useState('');
const insertModalRef = useRef<InsertModalRef>(null);
const isManualRefreshRef = useRef(false);
useEffect(() => {
if (documentId) {
fetchDocumentDetail();
}
}, [documentId]);
// 当文档加载完成且 progress === 1 时,加载分块列表
useEffect(() => {
if (document && document.progress === 1 && !isManualRefreshRef.current) {
ChunkList();
}
// 重置标志
isManualRefreshRef.current = false;
}, [document]);
// 监听 keywords 变化,重新搜索
useEffect(() => {
if (documentId && keywords && document?.progress === 1) {
setPage(1); // 重置页码
setChunkList([]); // 清空列表
ChunkList(1, false); // 重新加载第一页
}
}, [keywords]);
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
return [
{
key: 'file_name',
label: t('knowledgeBase.fileName') || '文件名',
value: doc.file_name ?? '-',
},
{
key: 'status',
label: t('knowledgeBase.status') || '进度',
value: doc.progress === 1 ? t('knowledgeBase.progressComplete') : t('knowledgeBase.progressing') ?? '-',
},
{
key: 'chunk_num',
label: t('knowledgeBase.chunk_num') || '分块数量',
value: doc.chunk_num ?? 0,
},
{
key: 'parser_id',
label: t('knowledgeBase.processingMode') || '处理模式',
value: doc.parser_id ?? '-',
},
{
key: 'created_at',
label: t('knowledgeBase.created_at') || '创建时间',
value: formatDateTime(doc.created_at, 'YYYY-MM-DD HH:mm:ss'),
},
{
key: 'updated_at',
label: t('knowledgeBase.updated_at') || '更新时间',
value: formatDateTime(doc.updated_at, 'YYYY-MM-DD HH:mm:ss'),
},
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
};
const fetchDocumentDetail = async () => {
if (!documentId) return;
setLoading(true);
try {
const response = await getDocumentDetail(documentId);
setDocument(response);
setInfoItems(formatDocumentInfo(response));
const url = `${imagePath}/api/files/${response.file_id}`
setFileUrl(url);
// ChunkList 会在 useEffect 中根据 document.progress 自动调用
} catch (error) {
console.error('获取文档详情失败:', error);
message.error(t('common.loadFailed') || '加载失败');
} finally {
setLoading(false);
}
};
const ChunkList = async (pageNum: number = 1, append: boolean = false, force: boolean = false) => {
if (!documentId) return;
// 如果不是强制刷新,且正在加载中,则跳过
if (!force && chunkLoading) {
return;
}
// 只有当文档处理完成时才获取分块列表
if (document && document.progress !== 1) {
return;
}
setChunkLoading(true);
try {
const response = await getDocumentChunkList({
kb_id: knowledgeBaseId,
document_id: documentId,
keywords: keywords || undefined,
page: pageNum,
pagesize: 20,
_t: force ? Date.now() : undefined, // 强制刷新时添加时间戳破坏缓存
});
// 转换数据格式以匹配 RecallTestData
const formattedChunks: RecallTestData[] = response.items.map((item: any) => ({
page_content: item.page_content || item.content || '',
vector: null,
metadata: {
doc_id: item.metadata.doc_id || '',
file_id: item.metadata.file_id || document?.file_id || '',
file_name: item.metadata.file_name || document?.file_name || '',
file_created_at: item.metadata.file_created_at || item.metadata.created_at || '',
document_id: item.metadata.document_id || documentId || '',
knowledge_id: item.metadata.knowledge_id || knowledgeBaseId || '',
sort_id: item.metadata.sort_id || item.id || 0,
score: item.metadata.score || null, // chunk 列表没有相似度分数
status: item.metadata.status,
},
children: null,
}));
if (append) {
setChunkList(prev => [...prev, ...formattedChunks]);
} else {
setChunkList(formattedChunks);
}
setHasMore(response.page?.has_next ?? false);
} catch (error) {
console.error('获取文档详情失败:', error);
message.error(t('common.loadFailed') || '加载失败');
} finally {
setChunkLoading(false);
}
};
const loadMoreChunks = () => {
const nextPage = page + 1;
setPage(nextPage);
ChunkList(nextPage, true);
};
const handleBack = () => {
if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/private`);
}
};
const handleSearch = (value?: string) => {
setKeywords(value || '');
};
const handleInsert = () => {
if (!documentId) {
message.error(t('knowledgeBase.documentIdRequired') || '文档ID不能为空');
return;
}
insertModalRef.current?.handleOpen(documentId);
};
// 处理插入/编辑内容
const handleInsertContent = async (_docId: string, content: string, chunkId?: string): Promise<boolean> => {
try {
if (chunkId) {
// 编辑模式:更新现有块
const response = await updateDocumentChunk(knowledgeBaseId || '', documentId, chunkId, { content });
// 直接更新前端列表,不等待后端缓存刷新
setChunkList(prev => prev.map(item =>
item.metadata?.doc_id === chunkId
? { ...item, page_content: response.page_content || content }
: item
));
// 编辑模式返回特殊标记,告诉 InsertModal 不要调用 onSuccess
return true;
} else {
// 插入模式:创建新块
await createDocumentChunk(knowledgeBaseId || '', documentId, { content });
return true;
}
} catch (error) {
console.error('操作失败:', error);
return false;
}
};
// 处理点击文本块
const handleChunkClick = (item: RecallTestData, index: number) => {
if (!documentId) return;
const chunkId = String(item.metadata?.doc_id || index);
insertModalRef.current?.handleOpen(documentId, item.page_content, chunkId);
};
// 插入成功后的回调(仅用于插入新块,编辑操作已在 handleInsertContent 中同步更新)
const handleInsertSuccess = () => {
// 设置手动刷新标志,防止 useEffect 重复调用
isManualRefreshRef.current = true;
// 重置页码
setPage(1);
// 等待后端处理完成,然后重新加载数据(仅用于插入新块的情况)
setTimeout(() => {
ChunkList(1, false, true).then(() => {
return fetchDocumentDetail();
}).catch(err => {
console.error('刷新失败:', err);
});
}, 1000);
};
const handleAdjustmentParameter = () =>{
if (!knowledgeBaseId || !document) return;
const targetFileId = document.id;
// 优先使用从 location 传递的 parentId其次使用 document.parent_id最后使用 knowledgeBaseId
const parentId = locationParentId ?? document.parent_id ?? document.kb_id ?? knowledgeBaseId;
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
state: {
source: 'local',
knowledgeBaseId,
parentId,
startStep: 'parameterSettings',
fileId: targetFileId,
},
});
}
const handleDownload = () => {
if (!document) return;
downloadFile(document.file_id || '', document.file_name)
};
const onChange = (checked: boolean) => {
updateDocument(documentId, {
status: checked ? 1 : 0,
});
}
if (loading) {
return (
<div className="rb:flex rb:items-center rb:justify-center rb:h-full">
<Spin size="large" />
</div>
);
}
if (document?.progress !== 1) {
return (
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
{/* 文档预览 */}
{fileUrl && (
<div className='rb:flex-1 rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4 rb:overflow-hidden'>
<h3 className="rb:text-sm rb:font-medium rb:mb-3">
{t('knowledgeBase.documentPreview') || '文档预览'}
</h3>
<DocumentPreview
fileUrl={fileUrl}
fileName={document?.file_name}
fileExt={document?.file_ext}
height="calc(100% - 40px)"
mode="google"
showModeSwitch={true}
/>
</div>
)}
</div>
);
}
return (<>
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
{/* 头部 */}
<div className="rb:flex rb:flex-col rb:text-left rb:mb-6">
<div className='rb:flex rb:items-center rb:justify-between'>
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
</div>
<div className="rb:flex rb:items-center rb:justify-between rb:gap-4">
<div className="rb:flex rb:gap-2 rb:items-center rb:text-xl rb:font-semibold rb:text-gray-800 ">
{document.file_name || t('knowledgeBase.documentDetails') || '文档详情'}
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={document.status === 1} onChange={onChange}/>
</div>
<div className='rb:flex rb:gap-3 rb:items-center'>
<SearchInput
placeholder={t('knowledgeBase.search')}
onSearch={handleSearch}
defaultValue={keywords}
/>
<Button type='primary' onClick={handleAdjustmentParameter}>{t('knowledgeBase.adjustmentParameter') || '调整参数'}</Button>
<Button type="primary" onClick={handleInsert}>{t('knowledgeBase.insert') || '插入'}</Button>
</div>
</div>
</div>
{/* 内容区域 */}
<div className="rb:flex rb:h-full rb:gap-4 rb:flex-1 rb:overflow-hidden">
{/* 左侧:文档信息 */}
<div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'>
<div className='rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
<InfoPanel
title={t('knowledgeBase.documentInfo') || '文档信息'}
items={infoItems}
/>
<Button type='primary' onClick={handleDownload} className="rb:mt-4 rb:w-full">
{t('knowledgeBase.downloadOriginal')}
</Button>
</div>
</div>
{/* 右侧:分块列表 */}
<div
id="chunkScrollableDiv"
className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:p-6 rb:overflow-y-auto"
>
<h2 className="rb:text-lg rb:font-medium rb:mb-4">
{t('knowledgeBase.chunkList') || '分块列表'}
</h2>
<RecallTestResult
data={chunkList}
showEmpty={false}
hasMore={hasMore}
loadMore={loadMoreChunks}
loading={chunkLoading}
scrollableTarget="chunkScrollableDiv"
editable={true}
onItemClick={handleChunkClick}
/>
</div>
</div>
{/* 插入内容弹窗 */}
<InsertModal
ref={insertModalRef}
onInsert={handleInsertContent}
onSuccess={handleInsertSuccess}
/>
</div>
</>);
};
export default DocumentDetails;

View File

@@ -0,0 +1,113 @@
/* 去掉所有默认白色背景 */
.customTree,
.customTree *,
.customTree .ant-tree,
.customTree .ant-tree-list,
.customTree .ant-tree-list-holder,
.customTree .ant-tree-list-holder-inner,
.customTree .ant-tree-list-holder-inner > div,
.customTree .ant-tree-list-holder-inner > div > div {
background: transparent !important;
background-color: transparent !important;
}
/* 节点内容区域 - 默认透明 */
.customTree .ant-tree-node-content-wrapper {
background: transparent !important;
background-color: transparent !important;
height: 40px !important;
display: flex !important;
align-items: center !important;
padding: 0 8px !important;
border-radius: 4px;
transition: background-color 0.2s ease !important;
flex: 1 !important;
min-width: 0 !important;
}
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:before,
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover:before {
background: #FFFFFF !important;
}
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper,
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover{
color: #000 !important;
}
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-switcher,
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-draggable-icon{
color: #000;
}
.customTree .ant-tree-switcher {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 40px !important;
line-height: 40px !important;
flex-shrink: 0 !important;
}
.customTree .ant-tree-switcher::before{
background: transparent !important;
background-color: transparent !important;
}
/* Switcher 图标样式 - 收起状态(默认向右) */
.customTree .ant-tree-switcher .ant-tree-switcher-icon,
.customTree .ant-tree-switcher img {
transition: transform 0.3s ease !important;
transform: rotate(0deg) !important;
}
/* Switcher 图标样式 - 展开状态向下旋转90度 */
.customTree .ant-tree-switcher_open .ant-tree-switcher-icon,
.customTree .ant-tree-switcher_open img,
.customTree .ant-tree-switcher.ant-tree-switcher_open img {
transform: rotate(90deg) !important;
}
/* 如果使用 ant-tree-switcher_close 类 */
.customTree .ant-tree-switcher_close .ant-tree-switcher-icon,
.customTree .ant-tree-switcher_close img {
transform: rotate(0deg) !important;
}
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 16px !important;
height: 16px !important;
margin-right: 4px !important;
line-height: 1 !important;
flex-shrink: 0 !important;
}
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle img {
width: 16px !important;
height: 16px !important;
display: block !important;
vertical-align: middle !important;
}
.customTree .ant-tree-title {
display: flex !important;
align-items: center !important;
flex: 1 !important;
height: 40px !important;
line-height: 40px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.customTree .ant-tree-child-tree {
margin: 0 !important;
}
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle + .ant-tree-title {
margin-left: 0 !important;
}

View File

@@ -0,0 +1,526 @@
import { useEffect, useState, useRef, type FC } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Switch, Button, Dropdown, Space, Modal, message } from 'antd';
import type { MenuProps } from 'antd';
import SearchInput from '@/components/SearchInput'
import Table, { type TableRef } from '@/components/Table'
import type { ColumnsType } from 'antd/es/table';
import type { AnyObject } from 'antd/es/_util/type';
import { MoreOutlined } from '@ant-design/icons';
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
import textIcon from '@/assets/images/knowledgeBase/text.png';
import imageIcon from '@/assets/images/knowledgeBase/image.png';
import blankIcon from '@/assets/images/knowledgeBase/blankDocument.png';
import templateIcon from '@/assets/images/knowledgeBase/template.png';
import backupIcon from '@/assets/images/knowledgeBase/backup.png';
import editIcon from '@/assets/images/knowledgeBase/edit.png';
import { getKnowledgeBaseDetail, deleteDocument, downloadFile, updateKnowledgeBase } from '../service';
import type {
CreateModalRef,
KnowledgeBaseListItem,
RecallTestDrawerRef,
CreateFolderModalRef,
CreateImageModalRef,
ShareModalRef,
CreateDatasetModalRef,FolderFormData,
KnowledgeBaseDocumentData
} from '../types';
import RecallTestDrawer from '../components/RecallTestDrawer';
import CreateFolderModal from '../components/CreateFolderModal';
import CreateModal from '../components/CreateModal';
import ShareModal from '../components/ShareModal';
import CreateDatasetModal from '../components/CreateDatasetModal';
import CreateImageDataset from '../components/CreateImageDataset';
import FolderTree, { type TreeNodeData } from '../components/FolderTree';
import { formatDateTime } from '@/utils/format';
import './Private.css'
const { confirm } = Modal
// 树节点数据类型
const Private: FC = () => {
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
const location = useLocation();
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const [parentId, setParentId] = useState<string | undefined>(knowledgeBaseId);
const [loading, setLoading] = useState(false);
const tableRef = useRef<TableRef>(null);
const [tableApi, setTableApi] = useState<string | undefined>(undefined);
const recallTestDrawerRef = useRef<RecallTestDrawerRef>(null);
const createFolderModalRef = useRef<CreateFolderModalRef>(null);
const createImageDataset = useRef<CreateImageModalRef>(null)
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
const [folder, setFolder] = useState<FolderFormData | null>({
kb_id:knowledgeBaseId ?? '',
parent_id:parentId ?? ''
});
const [keywords, setKeywords] = useState<string>('');
const [query, setQuery] = useState<Record<string, unknown>>({
orderby: 'created_at',
desc: true,
});
const modalRef = useRef<CreateModalRef>(null)
const shareModalRef = useRef<ShareModalRef>(null);
const datasetModalRef = useRef<CreateDatasetModalRef>(null);
const [folderTreeRefreshKey, setFolderTreeRefreshKey] = useState(0);
useEffect(() => {
if (knowledgeBaseId) {
let url = `/documents/${knowledgeBaseId}/${parentId}/documents`;
setTableApi(url);
fetchKnowledgeBaseDetail(knowledgeBaseId);
}
}, [knowledgeBaseId]);
// 监听 tableApi 变化,自动刷新表格数据
useEffect(() => {
if (tableApi) {
tableRef.current?.loadData();
}
}, [tableApi]);
// 监听 location state 变化,如果有 refresh 标志则刷新列表
useEffect(() => {
const state = location.state as { refresh?: boolean; timestamp?: number } | null;
if (state?.refresh) {
tableRef.current?.loadData();
// 清除 state避免重复刷新
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state]);
const fetchKnowledgeBaseDetail = async (id: string) => {
setLoading(true);
try {
const res = await getKnowledgeBaseDetail(id);
setKnowledgeBase(res.data || res);
} finally {
setLoading(false);
}
};
// 处理树节点选择
const onSelect = (selectedKeys: React.Key[], info: any) => {
if (!selectedKeys.length) return;
if (!folder) return;
const node = info.node as TreeNodeData;
const f = {
...folder,
parent_id: String(selectedKeys[0]),
}
let url = `/documents/${knowledgeBaseId}/${String(selectedKeys[0])}/documents`;
setTableApi(url);
setParentId(String(selectedKeys[0]))
setFolder(f)
// 根据节点类型执行不同操作
if (node.type === 'folder') {
// 文件夹:展开/收起
} else if (node.type === 'text' || node.type === 'image' || node.type === 'dataset') {
// 文件:打开详情
}
};
// 处理树节点展开
const onExpand = (expandedKeys: React.Key[], info: any) => {
};
// create / import list
const createItems: MenuProps['items'] = [
{
key: '1',
icon: <img src={folderIcon} alt="dataset" style={{ width: 16, height: 16 }} />,
label: t('knowledgeBase.folder'),
onClick: () => {
let f: FolderFormData | null = null;
f = {
kb_id: knowledgeBase?.id ?? '',
parent_id:folder?.parent_id ?? knowledgeBase?.id ?? '',
}
// setFolder(f);
createFolderModalRef?.current?.handleOpen(f as FolderFormData);
},
},
{
key: '2',
icon: <img src={textIcon} alt="text" style={{ width: 16, height: 16 }} />,
label: (<span>{t('knowledgeBase.text')} {t('knowledgeBase.dataset')}</span>),
onClick: () => {
datasetModalRef?.current?.handleOpen(knowledgeBase?.id,folder?.parent_id ?? knowledgeBase?.id ?? '');
},
},
// 暂时未实现
// {
// key: '3',
// icon: <img src={imageIcon} alt="image" style={{ width: 16, height: 16 }} />,
// label: t('knowledgeBase.imageDataSet'),
// onClick: () => {
// createImageDataset?.current?.handleOpen(knowledgeBaseId || '', parentId || '')
// },
// },
// {
// key: '4',
// icon: <img src={blankIcon} alt="blank" style={{ width: 16, height: 16 }} />,
// label: t('knowledgeBase.blankDataset'),
// onClick: () => {
// handleCreate('folder'); // 传入 type: 'folder'
// },
// },
// {
// key: '5',
// type: 'divider',
// },
// {
// key: '6',
// icon: <img src={templateIcon} alt="import" style={{ width: 16, height: 16 }} />,
// label: t('knowledgeBase.importTemplate'),
// onClick: () => {
// handleCreate('folder'); // 传入 type: 'folder'
// },
// },
// {
// key: '7',
// icon: <img src={backupIcon} alt="import" style={{ width: 16, height: 16 }} />,
// label: t('knowledgeBase.importBackup'),
// onClick: () => {
// handleCreate('folder'); // 传入 type: 'folder'
// },
// },
];
//
const handleCreate = (type: string) => {
console.log('create', type);
}
// 处理开关
const onChange = (checked: boolean) => {
updateKnowledgeBase(knowledgeBaseId || '', {
status: checked ? 1 : 0,
});
console.log(`switch to ${checked}`);
};
// 处理搜索
const handleSearch = (value?: string) => {
setQuery({ ...query, keywords: value })
}
// 处理分享
const handleShare = () => {
shareModalRef?.current?.handleOpen(knowledgeBaseId,knowledgeBase);
}
// 处理分享回调,接收选中的数据
const handleShareCallback = (selectedData: { checkedItems: any[], selectedItem: any | null }) => {
console.log('选中的数据:', selectedData);
// checkedItems: 所有 checked 为 true 的数据
// selectedItem: 当前选中的项curIndex 对应的数据)
// 在这里处理分享逻辑
}
const handleCreateDatasetCallback = (payload: { value: number; title: string; description: string }) => {
console.log('创建数据集:', payload);
}
// 处理设置
const handleSetting = () => {
modalRef?.current?.handleOpen(knowledgeBase, '');
}
// 处理召回测试
const handleRecallTest = () => {
recallTestDrawerRef?.current?.handleOpen(knowledgeBaseId);
}
// new / import
const handelCreateOrImport = () => {
}
// 生成下拉菜单项(根据当前 row
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => [
{
key: '1',
label: t('knowledgeBase.rechunking'),
onClick: () => {
handleRechunking(row);
},
},
{
key: '2',
label: t('knowledgeBase.download'),
onClick: () => {
handleDownload(row);
},
},
{
key: '3',
label: t('knowledgeBase.delete'),
onClick: () => {
handleDelete(row);
},
}
];
const handleRechunking = (item: KnowledgeBaseListItem) => {
if (!knowledgeBaseId) return;
const document = item as unknown as KnowledgeBaseDocumentData;
const targetFileId = document?.id || document?.file_id;
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
state: {
source: 'local',
knowledgeBaseId,
parentId: parentId ?? knowledgeBaseId,
startStep: 'parameterSettings',
fileId: targetFileId,
},
});
}
const handleDownload = (item: KnowledgeBaseListItem) => {
const document = item as unknown as KnowledgeBaseDocumentData;
const targetFileId = document?.file_id ?? '';
const fileName = document?.file_name ?? '';
downloadFile(targetFileId, fileName);
}
const handleDelete = (item: any) => {
confirm({
title: t('common.deleteWarning'),
content: t('common.deleteWarningContent', { content: item.file_name }),
onOk: () => {
deleteDocument(item.id)
.then(() => {
messageApi.success(t('common.deleteSuccess'));
// 刷新表格数据
tableRef.current?.loadData();
})
.catch((err: any) => {
console.log('删除失败', err);
});
},
onCancel: () => {
console.log('取消删除');
},
});
}
// 表格列配置
const columns: ColumnsType = [
{
title: t('knowledgeBase.name'),
dataIndex: 'file_name',
key: 'file_name',
render: (text: string, record: AnyObject) => {
const document = record as KnowledgeBaseDocumentData;
return (
<span
className="rb:text-blue-600 rb:cursor-pointer rb:hover:underline"
onClick={() => {
if (knowledgeBaseId && document.id) {
navigate(`/knowledge-base/${knowledgeBaseId}/DocumentDetails`,{
state: {
documentId: document.id,
parentId: parentId ?? knowledgeBaseId,
},
});
}
}}
>
{text}
</span>
);
},
},
{
title: t('knowledgeBase.processingMode'),
dataIndex: 'parser_id',
key: 'parser_id',
},
{
title: t('knowledgeBase.dataSize'),
dataIndex: 'file_size',
key: 'file_size',
},
{
title: t('knowledgeBase.createUpdateTime'),
dataIndex: 'created_at',
key: 'created_at',
render:(value:string) => {
return(
<span>{formatDateTime(value,'YYYY-MM-DD HH:mm:ss')}</span>
)
}
},
{
title: t('knowledgeBase.status'),
dataIndex: 'progress',
key: 'progress',
render: (value: string | number, record: AnyObject) => {
return (
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
<span
className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full"
style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }}
></span>
<span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span>
</span>
);
}
},
{
title: t('common.operation'),
key: 'action',
fixed: 'right',
width: 100,
render: (_, record) => (
<Space size="middle">
<Dropdown
menu={{ items: getOptMenuItems(record as KnowledgeBaseListItem) }}
trigger={['click']}
>
<MoreOutlined className='rb:text-base rb:font-semibold'/>
</Dropdown>
</Space>
),
},
];
// 刷新列表数据
if (loading) {
return <div>...</div>;
}
if (!knowledgeBase) {
return <div></div>;
}
const refreshDirectoryTree = async () => {
// 先刷新知识库详情,确保数据是最新的
await fetchKnowledgeBaseDetail(knowledgeBase.id);
// 添加短暂延迟,确保后端数据已经完全更新
await new Promise(resolve => setTimeout(resolve, 300));
// 然后刷新文件夹树
setFolderTreeRefreshKey((prev) => prev + 1);
if (!folder) {
setFolder({
kb_id: knowledgeBaseId ?? '',
parent_id: parentId ?? knowledgeBaseId ?? ''
});
}
}
const handleRootTreeLoad = (nodes: TreeNodeData[] | null) => {
if (!nodes || nodes.length === 0) {
setFolder(null);
} else {
// 如果有节点且 folder 为 null重新设置 folder
if (!folder) {
setFolder({
kb_id: knowledgeBaseId ?? '',
parent_id: parentId ?? knowledgeBaseId ?? ''
});
}
}
};
const handleEditFolder = () => {
const f = {
id:knowledgeBase.id,
parent_id:knowledgeBase.parent_id,
kb_id:knowledgeBase.id,
folder_name:knowledgeBase.name
}
// setFolder(f)
createFolderModalRef?.current?.handleOpen(f,'edit');
}
const handleRefreshTable = () => {
// 刷新表格数据
tableRef.current?.loadData();
}
return (
<>
{contextHolder}
<div className="rb:flex rb:h-full rb:gap-4">
{folder && (
<div className="rb:w-80 rb:flex-shrink-0 rb:h-[calc(100%+40px)] rb:mt-[-16px] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
<FolderTree
multiple
className="customTree"
style={{ background: 'transparent' }}
onSelect={onSelect}
onExpand={onExpand}
knowledgeBaseId={knowledgeBaseId ?? ''}
refreshKey={folderTreeRefreshKey}
onRootLoad={handleRootTreeLoad}
/>
</div>
)}
<div className='rb:flex-1 rb:min-w-0'>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-4">
<div className="rb:flex-col">
<div className="rb:flex rb:items-center rb:gap-3">
<h1 className="rb:text-xl rb:font-medium rb:text-gray-800">{knowledgeBase.name}</h1>
<div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded"
onClick={handleEditFolder}
>
<img src={editIcon} alt="edit" className="rb:w-[14px] rb:h-[14px" />
<span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span>
</div>
</div>
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
<span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span>
<span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>
</div>
</div>
{/* <div className='rb:flex'> */}
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={knowledgeBase.status === 1} onChange={onChange}/>
{/* </div> */}
</div>
<div className='rb:flex rb:items-center rb:justify-between rb:mb-4'>
<SearchInput placeholder={t('knowledgeBase.search')} onSearch={handleSearch} />
<div className='rb:flex-1 rb:flex rb:items-center rb:justify-end rb:gap-2.5'>
<Button onClick={handleShare}>{t('knowledgeBase.share')}</Button>
<Button onClick={handleRecallTest}>{t('knowledgeBase.recallTest')}</Button>
<Button onClick={handleSetting}>{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.setting')}</Button>
<Dropdown menu={{ items: createItems }} trigger={['click']}>
<Button type="primary" onClick={handelCreateOrImport} >+ {t('knowledgeBase.createImport')}</Button>
</Dropdown>
</div>
</div>
<div className="rb:rounded rb:max-h-[calc(100%-100px)] rb:overflow-y-auto">
<Table
ref={tableRef}
apiUrl={tableApi}
apiParams={query as Record<string, unknown>}
columns={columns}
rowKey="id"
scrollX={1500}
/>
</div>
</div>
<RecallTestDrawer
ref={recallTestDrawerRef}
/>
<CreateFolderModal
ref={createFolderModalRef}
refreshTable={refreshDirectoryTree}
/>
<CreateModal
ref={modalRef}
refreshTable={handleRefreshTable}
/>
<ShareModal
ref={shareModalRef}
handleShare={handleShareCallback}
/>
<CreateDatasetModal
ref={datasetModalRef}
handleCreateDataset={handleCreateDatasetCallback}
/>
<CreateImageDataset
ref={createImageDataset}
refreshTable={refreshDirectoryTree}
/>
</div>
</>
);
};
export default Private;

View File

@@ -0,0 +1,158 @@
import { useEffect, useState, useRef, type FC } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { KnowledgeBaseListItem, RecallTestDrawerRef } from '../types';
import RecallTest from '../components/RecallTest';
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
import shareUserIcon from '@/assets/images/knowledgeBase/share-user.png';
import timestampIcon from '@/assets/images/knowledgeBase/timestamp.png';
//
import kbNameIcon from '@/assets/images/knowledgeBase/kb-name.png';
import kbDataIcon from '@/assets/images/knowledgeBase/kb-data.png';
import kbSizeIcon from '@/assets/images/knowledgeBase/kb-size.png';
import kbModelIcon from '@/assets/images/knowledgeBase/kb-model.png';
import kbHistoryIcon from '@/assets/images/knowledgeBase/kb-history.png';
import { getKnowledgeBaseDetail } from '../service';
import { formatDateTime } from '@/utils/format';
const Share: FC = () => {
const { t } = useTranslation();
const params = useParams<{ knowledgeBaseId: string }>();
const knowledgeBaseId = params.knowledgeBaseId;
const [loading, setLoading] = useState(false);
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
const recallTestRef = useRef<RecallTestDrawerRef>(null);
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
useEffect(() => {
console.log('Share.tsx - useParams result:', params);
console.log('Share.tsx - knowledgeBaseId:', knowledgeBaseId);
console.log('Share.tsx - typeof knowledgeBaseId:', typeof knowledgeBaseId);
if (knowledgeBaseId) {
fetchKnowledgeBaseDetail(knowledgeBaseId);
// 打开召回测试组件
setTimeout(() => {
console.log('Share.tsx - calling handleOpen with:', knowledgeBaseId);
recallTestRef.current?.handleOpen(knowledgeBaseId);
}, 100);
} else {
console.warn('Share.tsx - knowledgeBaseId is undefined or empty');
}
}, [knowledgeBaseId]);
const formatInfoItems = (data: KnowledgeBaseListItem): InfoItem[] => {
const items: InfoItem[] = [
{
key: 'name',
label: t('knowledgeBase.knowledgeBase') + ' ' + t('knowledgeBase.name'),
value: data.name ?? '-',
icon: kbNameIcon,
},
{
key: 'doc_num',
label: t('knowledgeBase.doc_num'),
value: data.doc_num ?? 0,
icon: kbDataIcon,
},
{
key: 'chunk_num',
label: t('knowledgeBase.chunk_num'),
value: data.chunk_num ?? 0,
icon: kbSizeIcon,
},
{
key: 'embedding_id',
label: t('knowledgeBase.embedding_id') + ' ' + 'model',
value: data.embedding?.name ?? '-',
icon: kbModelIcon,
},
{
key: 'llm_id',
label: t('knowledgeBase.llm_id') + ' ' + 'model',
value: data.llm?.name ?? '-',
icon: kbModelIcon,
},
{
key: 'image2text_id',
label: t('knowledgeBase.image2text_id') + ' ' + 'model',
value: data.image2text?.name ?? '-',
icon: kbModelIcon,
},
{
key: 'updated_at',
label: t('knowledgeBase.last_at'),
value: formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss'),
icon: kbHistoryIcon,
},
];
return items.filter((item) => {
return item.value !== null && item.value !== undefined && item.value !== '';
});
}
const fetchKnowledgeBaseDetail = (id: string) => {
setLoading(true);
getKnowledgeBaseDetail(id)
.then((res: any) => {
const data = res.data || res;
setKnowledgeBase(data);
setInfoItems(formatInfoItems(data));
})
.finally(() => {
setLoading(false);
});
};
// const handleBack = () => {
// navigate('/knowledge-base');
// };
if (loading) {
return <div>...</div>;
}
if (!knowledgeBase) {
return <div></div>;
}
return (
<div className="rb:flex rb:flex-col rb:h-full rb:max-h-full rb:overflow-hidden">
<div className="rb:flex rb:w-full rb:items-center rb:mb-2 rb:gap-2">
<h1 className="rb:text-xl rb:font-bold">{knowledgeBase.name}
<span className='rb:text-gray-500 rb:text-sm rb:ml-2 rb:font-normal'>(ID: {knowledgeBase.id})</span></h1>
{/* <p className="rb:text-gray-600 rb:mt-2">{knowledgeBase.description || t('knowledgeBase.noDescription')}</p> */}
<span className='rb:text-gray-800 rb:text-xs rb:border rb:border-[#369F21] rb:bg-[rgba(54,159,33,0.2)] rb:px-1 rb:py-[2px] rb:rounded'>{knowledgeBase.permission_id}</span>
</div>
<div className="rb:flex rb:w-full rb:items-center rb:mb-5 rb:gap-2">
<img src={shareUserIcon} className='rb:size-4 rb:ml-2' />
<span className='rb:text-gray-500 rb:text-xs'>{knowledgeBase.created_by}</span>
<img src={timestampIcon} className='rb:size-4 rb:ml-2' />
<span className='rb:text-gray-500 rb:text-xs'>{formatDateTime(knowledgeBase.created_at)}</span>
</div>
<div className="rb:flex rb:flex-1 rb:gap-4 rb:min-h-0">
<div className="rb:flex-1 rb:p-4 rb:border rb:flex rb:flex-col rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:overflow-hidden">
<div className='rb:flex rb:flex-col rb:txt-left rb:mb-5 rb:gap-2 rb:flex-shrink-0'>
<h1 className="rb:text-lg rb:font-bold">{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.recallTest')}</h1>
<span className='rb:text-gray-500 rb:text-xs'>{t('knowledgeBase.recallTestDescription')}</span>
</div>
<div className='rb:flex-1 rb:min-h-0'>
<RecallTest ref={recallTestRef} />
</div>
</div>
<div className='rb:w-80 rb:border rb:overflow-y-auto rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
<InfoPanel
title={t('knowledgeBase.knowledgeBaseInfo')}
items={infoItems}
/>
</div>
</div>
</div>
);
};
export default Share;

View File

@@ -0,0 +1,150 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-10 18:52:55
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-24 11:23:33
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import type { RadioChangeEvent } from 'antd';
import { Flex, Radio } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { CreateDatasetModalRef, CreateDatasetModalRefProps} from '../types';
import RbModal from '@/components/RbModal'
const style: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
};
const radioWrapperBaseStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
columnGap: 14, // 点与文字更宽的间距
width: '100%',
border: '1px solid #E5E5E5',
borderRadius: 8,
padding: 16,
};
const getActiveRadioStyle = (active: boolean): React.CSSProperties => ({
...radioWrapperBaseStyle,
border: active ? '1px solid #1677ff' : radioWrapperBaseStyle.border,
});
const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRefProps>((_props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
// const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
const [knowledgeBaseId, setKnowledgeBaseId] = useState<string | undefined>(undefined);
const [parentId, setParentId] = useState<string | undefined>(undefined);
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [value, setValue] = useState(0);
// const { handleCreateDataset: onCreate } = props || {};
const items = [
{
title: t('knowledgeBase.localFile'),
description: t('knowledgeBase.uploadFileTypes'),
},
// 暂时隐藏
// {
// title: t('knowledgeBase.webLink'),
// description: t('knowledgeBase.readStaticWebPage')
// },
// {
// title: t('knowledgeBase.customText'),
// description: t('knowledgeBase.manuallyInputText')
// },
]
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setLoading(false)
setVisible(false);
};
const handleOpen = (kb_id?: string,parent_id?: string) => {
setKnowledgeBaseId(kb_id);
setParentId(parent_id);
setVisible(true);
};
const handleCreateDataset = () => {
// // 获取所有 checked 为 true 的数据
// const checkedItems = testData.filter(item => item.checked);
// // 获取当前选中的项curIndex 对应的数据)
// const selectedItem = curIndex !== 9999 ? testData[curIndex] : null;
// // 调用父组件传递的回调函数,传递选中的数据
// onShare?.({
// checkedItems,
// selectedItem
// })
// const selected = items[value];
// onCreate?.({
// value,
// title: selected.title,
// description: selected.description,
// });
// 跳转到创建数据集页面并携带来源参数
const source = value === 0 ? 'local' : value === 1 ? 'link' : 'text';
if (knowledgeBaseId) {
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`,{
state: {
source: source,
knowledgeBaseId: knowledgeBaseId,
parentId: parentId,
}
});
}
// 关闭弹窗
handleClose();
}
const onChange = (e: RadioChangeEvent) => {
setValue(e.target.value);
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose,
handleCreateDataset
}));
return (
<RbModal
title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.text') + ' ' + t('knowledgeBase.dataset')}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
onOk={handleCreateDataset}
confirmLoading={loading}
>
<div className='rb:flex rb:flex-col rb:text-left'>
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.selectSource')}</h4>
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
<Radio.Group onChange={onChange} value={value} style={style}>
<Radio value={0} style={getActiveRadioStyle(value === 0)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[0].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[0].description}</span>
</Flex>
</Radio>
{/* <Radio value={1} style={getActiveRadioStyle(value === 1)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex>
</Radio>
<Radio value={2} style={getActiveRadioStyle(value === 2)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[2].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span>
</Flex>
</Radio> */}
</Radio.Group>
</div>
</div>
</RbModal>
);
});
export default CreateDatasetModal;

View File

@@ -0,0 +1,114 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import type { FolderFormData, KnowledgeBaseFormData, CreateFolderModalRef, CreateFolderModalRefProps } from '../types';
import RbModal from '@/components/RbModal'
import { createFolder, updateKnowledgeBase } from '../service';
const CreateFolderModal = forwardRef<CreateFolderModalRef,CreateFolderModalRefProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [folder, setFolder] = useState<FolderFormData>({} as FolderFormData);
const [form] = Form.useForm<FolderFormData>();
const [loading, setLoading] = useState(false)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setFolder({} as FolderFormData);
form.resetFields();
setLoading(false)
setVisible(false);
};
const handleOpen = (folder?: FolderFormData | null) => {
if (folder) {
setFolder(folder);
// 设置表单值
form.setFieldsValue({
folder_name: folder.folder_name,
parent_id: folder.parent_id ?? '',
kb_id: folder.kb_id ?? '',
});
} else {
// 新建时,重置表单并设置默认值
form.resetFields();
form.setFieldsValue({
parent_id: '',
kb_id: ''
});
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields({ validateOnly: true })
.then(async () => {
setLoading(true)
const formValues = form.getFieldsValue();
const payload: FolderFormData = {
...formValues,
parent_id: folder.parent_id ?? '',
kb_id: folder.kb_id ?? '',
}
const updatePayload: KnowledgeBaseFormData = {
id: folder.id ?? '',
name: formValues.folder_name ?? '',
}
const data = await (folder.id ? updateKnowledgeBase(folder.id ?? '', updatePayload) : createFolder(payload)) as any;
if(data) {
if (refreshTable) {
await refreshTable();
}
setLoading(false)
handleClose()
}else {
setLoading(false)
}
})
.catch((err) => {
console.log('err', err)
setLoading(false)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
// 根据 type 获取标题
const getTitle = () => {
if (folder.id) {
return t('common.edit') + ' ' + (folder.folder_name || '');
}
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.folder');
}
return (
<RbModal
title={getTitle()}
open={visible}
onCancel={handleClose}
okText={folder.id ? t('common.save') : t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
{/* <div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('model.basicParameters')}</div> */}
<Form.Item
name="folder_name"
label={t('knowledgeBase.name')}
>
<Input placeholder={t('knowledgeBase.name')} />
</Form.Item>
</Form>
</RbModal>
);
});
export default CreateFolderModal;

View File

@@ -0,0 +1,151 @@
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import type { UploadFile } from 'antd';
import type { CreateImageModalRef, CreateImageMoealRefProps,UploadFileResponse } from '../types';
import type { UploadRequestOption } from 'rc-upload/lib/interface';
import RbModal from '@/components/RbModal';
import UploadFiles from '@/components/Upload/UploadFiles';
import { uploadFile } from '../service';
interface ImageDatasetFormData {
name: string;
images: UploadFile[];
}
const CreateImageDataset = forwardRef<CreateImageModalRef, CreateImageMoealRefProps>(
({ refreshTable }, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ImageDatasetFormData>();
const [loading, setLoading] = useState(false);
const [kbId, setKbId] = useState<string>('');
const [parentId, setParentId] = useState<string>('');
const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null);
const handleClose = () => {
form.resetFields();
uploadRef.current?.clearFiles();
setLoading(false);
setVisible(false);
setKbId('');
setParentId('');
};
const handleOpen = (kb_id: string, parent_id: string) => {
setKbId(kb_id);
setParentId(parent_id);
form.resetFields();
uploadRef.current?.clearFiles();
setVisible(true);
};
const handleSave = async () => {
try {
await form.validateFields();
setLoading(true);
const fileList = uploadRef.current?.fileList || [];
if (fileList.length === 0) {
throw new Error(t('knowledgeBase.pleaseUploadImages'));
}
// 上传所有图片
const uploadPromises = fileList.map(async (file) => {
if (file.originFileObj) {
const formData = new FormData();
formData.append('file', file.originFileObj);
return uploadFile(formData, {
kb_id: kbId,
parent_id: parentId,
});
}
return null;
});
await Promise.all(uploadPromises);
if (refreshTable) {
await refreshTable();
}
handleClose();
} catch (err) {
console.error('创建图片数据集失败:', err);
} finally {
setLoading(false);
}
};
useImperativeHandle(ref, () => ({
handleOpen,
}));
// 上传文件
const handleUpload = (options: UploadRequestOption) => {
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
const formData = new FormData();
formData.append(filename, file as File);
if (kbId) {
formData.append('kb_id', kbId);
}
if (parentId) {
formData.append('parent_id', parentId);
}
uploadFile(formData, {
kb_id: kbId,
parent_id: parentId,
onUploadProgress: (event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.({ percent }, file);
},
})
.then((res: UploadFileResponse) => {
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
// 上传成功
}
})
.catch((error) => {
onError?.(error as Error);
});
};
return (
<RbModal
title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.imageDataSet')}`}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('knowledgeBase.datasetName')}
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterDatasetName') }]}
>
<Input placeholder={t('knowledgeBase.pleaseEnterDatasetName')} />
</Form.Item>
<Form.Item label={t('knowledgeBase.uploadImages')}>
<UploadFiles
isCanDrag={true}
fileSize={50}
multiple={true}
maxCount={99}
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']}
customRequest={handleUpload}
/>
</Form.Item>
</Form>
</RbModal>
);
}
);
export default CreateImageDataset;

View File

@@ -0,0 +1,260 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { Form, Input, Select, Modal } from 'antd';
import { useTranslation } from 'react-i18next';
import type { KnowledgeBaseListItem, KnowledgeBaseFormData, CreateModalRef, CreateModalRefProps } from '../types';
import { getModelTypeList, getModelList, createKnowledgeBase, updateKnowledgeBase } from '../service'
import RbModal from '@/components/RbModal'
const { TextArea } = Input;
const { confirm } = Modal
const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [modelTypeList, setModelTypeList] = useState<string[]>([]);
const [modelOptionsByType, setModelOptionsByType] = useState<Record<string, { label: string; value: string }[]>>({});
const [datasets, setDatasets] = useState<KnowledgeBaseListItem | null>(null);
const [currentType, setCurrentType] = useState<string>('General'); // 保存当前 type
const [form] = Form.useForm<KnowledgeBaseFormData>();
const [loading, setLoading] = useState(false)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setDatasets(null);
form.resetFields();
setLoading(false)
setVisible(false);
};
const typeToFieldKey = (type: string): string => {
switch ((type || '').toLowerCase()) {
case 'embedding':
return 'embedding_id';
case 'llm':
return 'llm_id';
case 'image2text':
return 'image2text_id';
case 'rerank':
case 'reranker':
return 'reranker_id';
case 'chat':
return 'chat_id';
default:
return `${type.toLowerCase()}_id`;
}
};
const fetchModelLists = async (types: string[]) => {
// 如果 types 中包含 'llm',也需要获取 'chat' 的数据
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types;
const entries = await Promise.all(typesToFetch.map(async (tp) => {
try {
const res = await getModelList(tp === 'image2text' ? 'chat' : tp, { page: 1, pagesize: 100 });
const options = (res?.items || []).map((m: any) => ({ label: m.name, value: m.id }));
return [tp, options] as [string, { label: string; value: string }[]];
} catch {
return [tp, []] as [string, { label: string; value: string }[]];
}
}));
const next: Record<string, { label: string; value: string }[]> = {};
entries.forEach(([k, v]) => { next[k] = v; });
setModelOptionsByType(next);
};
const setBaseFields = (record: KnowledgeBaseListItem | null, type?: string) => {
if (!record) {
form.resetFields();
const defaults: Partial<KnowledgeBaseFormData> = {
permission_id: 'Private',
type: type || currentType,
};
form.setFieldsValue(defaults);
return;
}
const baseValues: Partial<KnowledgeBaseFormData> = {
name: record.name,
description: record.description,
permission_id: record.permission_id || 'Private',
type: type || record.type || currentType,
status: record.status,
};
form.setFieldsValue(baseValues);
};
const setDynamicModelFields = (record: KnowledgeBaseListItem | null, types: string[]) => {
if (!record || !types.length) return;
const dynamicValues: Record<string, string | undefined> = {};
const source = record as unknown as Record<string, unknown>;
types.forEach((tp) => {
const fieldKey = typeToFieldKey(tp);
const fieldValue = source[fieldKey];
if (typeof fieldValue === 'string') {
dynamicValues[fieldKey] = fieldValue;
}
});
if (Object.keys(dynamicValues).length) {
form.setFieldsValue(dynamicValues as Partial<KnowledgeBaseFormData>);
}
};
const handleOpen = (record?: KnowledgeBaseListItem | null, type?: string) => {
setDatasets(record || null);
const nextType = type || currentType;
setCurrentType(nextType);
setBaseFields(record || null, nextType);
getTypeList(record || null);
setVisible(true);
};
const getTypeList = async (record: KnowledgeBaseListItem | null) => {
const response = await getModelTypeList();
const types = Array.isArray(response) ? [...response.filter(type => type !== 'chat'),'image2text'] : [];
setModelTypeList(types);
if (types.length) {
await fetchModelLists(types);
setDynamicModelFields(record, types);
} else {
setModelOptionsByType({});
}
};
useEffect(() => {
if (!visible) return;
setBaseFields(datasets, currentType);
setDynamicModelFields(datasets, modelTypeList);
}, [visible, datasets, currentType, modelTypeList]);
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
const formValues = form.getFieldsValue();
const payload: KnowledgeBaseFormData = {
...formValues,
type: formValues.type || currentType,
permission_id: formValues.permission_id || 'Private',
parent_id: datasets?.parent_id || undefined,
};
const submit = datasets?.id
? updateKnowledgeBase(datasets.id, payload)
: createKnowledgeBase(payload);
submit
.then(() => {
if (refreshTable) {
refreshTable();
}
handleClose();
})
.catch(() => {
setLoading(false);
});
}).catch((err) => {
console.log('Validation failed:', err)
});
}
const handleChange = (value: string, tp: string) => {
// 只在编辑模式且类型为 embedding 时触发提示
if (datasets?.id && tp.toLowerCase() === 'embedding') {
const fieldKey = typeToFieldKey(tp);
// 从原始 datasets 对象中获取之前的值
const previousValue = (datasets as any)[fieldKey];
confirm({
title: t('common.updateWarning'),
content: t('knowledgeBase.updateEmbeddingContent'),
onOk: () => {
// 确定时什么也不做,保持新值
},
onCancel: () => {
// 取消时恢复之前的值
form.setFieldsValue({ [fieldKey]: previousValue } as any);
},
});
}
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
// 根据 type 获取标题
const getTitle = () => {
if (datasets?.id) {
return t('knowledgeBase.edit') + ' ' + datasets.name;
}
if (currentType === 'Folder') {
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.folder');
}
return t('knowledgeBase.createA') + ' ' + t('knowledgeBase.knowledgeBase');
};
const dynamicTypeList = useMemo(() => modelTypeList.filter((tp) => (modelOptionsByType[tp] || []).length), [modelTypeList, modelOptionsByType]);
return (
<RbModal
title={getTitle()}
open={visible}
onCancel={handleClose}
okText={datasets?.id ? t('common.save') : t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
initialValues={{
permission_id: 'Private', // 设置 permission_id 的默认值
type: currentType,
}}
>
{/* <div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('model.basicParameters')}</div> */}
{!datasets?.id && (
<Form.Item
name="name"
label={t('knowledgeBase.createForm.name')}
rules={[{ required: true, message: t('knowledgeBase.createForm.nameRequired') }]}
>
<Input placeholder={t('knowledgeBase.createForm.name')} />
</Form.Item>
)}
<Form.Item name="description" label={t('knowledgeBase.createForm.description')}>
<TextArea rows={2} placeholder={t('knowledgeBase.createForm.description')} />
</Form.Item>
{currentType !== 'Folder' && dynamicTypeList.map((tp) => {
const fieldKey = typeToFieldKey(tp);
// 当 tp 为 'llm' 时,合并 llm 和 chat 的选项
const options = tp.toLowerCase() === 'llm'
? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])]
: modelOptionsByType[tp] || [];
return (
<Form.Item
key={tp}
name={fieldKey as keyof KnowledgeBaseFormData}
label={t(`knowledgeBase.createForm.${fieldKey}`) + ' ' + 'model'}
rules={[{ required: true, message: t('knowledgeBase.createForm.modelRequired') }]}
>
<Select
options={options}
placeholder={t(`knowledgeBase.createForm.${fieldKey}`)}
allowClear={false}
showSearch
optionFilterProp="label"
onChange={(value) => handleChange(value, tp)}
/>
</Form.Item>
);
})}
</Form>
</RbModal>
);
});
export default CreateModal;

View File

@@ -0,0 +1,91 @@
import { useState, useEffect, type FC } from 'react';
import { Select, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { DELIMITER_OPTIONS, isCustomDelimiter } from '../constants/delimiter';
interface DelimiterSelectorProps {
value?: string | null;
onChange?: (value: string | undefined) => void;
placeholder?: string;
className?: string;
}
const DelimiterSelector: FC<DelimiterSelectorProps> = ({
value,
onChange,
placeholder,
className = '',
}) => {
const { t } = useTranslation();
// 默认值为空字符串(不设置)
const [selectedValue, setSelectedValue] = useState<string>(value || '');
const [customValue, setCustomValue] = useState<string>('');
const [showCustomInput, setShowCustomInput] = useState(false);
useEffect(() => {
// 检查当前值是否为自定义值
if (value && isCustomDelimiter(value) && value !== 'custom') {
setSelectedValue('custom');
setCustomValue(value);
setShowCustomInput(true);
} else {
setSelectedValue(value || '');
setShowCustomInput(value === 'custom');
}
}, [value]);
const handleSelectChange = (val: string) => {
setSelectedValue(val);
if (val === 'custom') {
setShowCustomInput(true);
// 如果已有自定义值,使用它;否则等待用户输入
if (customValue) {
onChange?.(customValue);
} else {
// 自定义但还没输入值,暂不触发 onChange
onChange?.(undefined);
}
} else if (val === '') {
// 选择"不设置"时,返回 undefined不传递该参数
setShowCustomInput(false);
onChange?.(undefined);
} else {
setShowCustomInput(false);
onChange?.(val);
}
};
const handleCustomInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setCustomValue(val);
// 只有当输入不为空时才触发 onChange
onChange?.(val || undefined);
};
return (
<div className={`rb:flex rb:gap-2 ${className}`}>
<Select
value={selectedValue}
onChange={handleSelectChange}
placeholder={placeholder || t('knowledgeBase.selectDelimiter') || '请选择分隔符'}
className='rb:w-full'
options={DELIMITER_OPTIONS.map(opt => ({
label: opt.label,
value: opt.value,
}))}
/>
{showCustomInput && (
<Input
value={customValue}
onChange={handleCustomInputChange}
placeholder={t('knowledgeBase.customDelimiterPlaceholder') || '请输入自定义分隔符'}
maxLength={50}
/>
)}
</div>
);
};
export default DelimiterSelector;

View File

@@ -0,0 +1,365 @@
import { useMemo, useEffect, useState } from 'react';
import type { FC } from 'react';
import type { CSSProperties, Key, ReactNode } from 'react';
import { Tree } from 'antd';
import type { DataNode, TreeProps } from 'antd/es/tree';
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
import textIcon from '@/assets/images/knowledgeBase/text.png';
import imageIcon from '@/assets/images/knowledgeBase/image.png';
import datasetsIcon from '@/assets/images/knowledgeBase/datasets.png';
import switcherIcon from '@/assets/images/knowledgeBase/switcher.png';
import { getFolderList } from '../service';
const { DirectoryTree } = Tree;
const TEXT_EXTENSIONS = new Set([
'txt',
'md',
'rtf',
'doc',
'docx',
'pdf',
'csv',
'json',
'xml',
'html',
'htm',
'log',
]);
const IMAGE_EXTENSIONS = new Set([
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
'tiff',
'ico',
]);
export interface TreeNodeData {
key: Key;
title: ReactNode;
icon?: string;
switcherIcon?: string;
type?: string;
isLeaf?: boolean;
children?: TreeNodeData[];
}
interface FolderTreeProps {
knowledgeBaseId: string;
onSelect?: TreeProps['onSelect'];
onExpand?: TreeProps['onExpand'];
multiple?: boolean;
className?: string;
style?: CSSProperties;
refreshKey?: number;
onRootLoad?: (nodes: TreeNodeData[] | null) => void;
}
const renderIcon = (icon?: string) => {
if (!icon) return undefined;
return <img src={icon} alt="icon" style={{ width: 16, height: 16 }} />;
};
const transformTreeData = (nodes: TreeNodeData[]): DataNode[] =>
nodes.map((node) => {
const children = node.children && node.children.length > 0 ? transformTreeData(node.children) : undefined;
return {
key: node.key,
title: node.title ?? '',
icon: renderIcon(node.icon),
switcherIcon: renderIcon(node.switcherIcon),
isLeaf: node.isLeaf,
children,
};
});
const buildMockTreeData = (): TreeNodeData[] => ([
{
title: '数据集文件夹',
key: '0',
icon: folderIcon,
switcherIcon: switcherIcon,
type: 'folder',
children: [
{
title: '文本数据集',
key: '0-0',
icon: textIcon,
switcherIcon: switcherIcon,
type: 'text',
children: [
{
title: '子文件夹1',
key: '0-0-0',
icon: folderIcon,
switcherIcon: switcherIcon,
type: 'folder',
children: [
{
title: '文档1.txt',
key: '0-0-0-0',
icon: textIcon,
type: 'text',
},
{
title: '文档2.txt',
key: '0-0-0-1',
icon: textIcon,
type: 'text',
},
],
},
{
title: '子文件夹2',
key: '0-0-1',
icon: folderIcon,
switcherIcon: switcherIcon,
type: 'folder',
children: [
{
title: '嵌套文件夹',
key: '0-0-1-0',
icon: folderIcon,
switcherIcon: switcherIcon,
type: 'folder',
children: [
{
title: '深度文档.txt',
key: '0-0-1-0-0',
icon: textIcon,
type: 'text',
},
],
},
],
},
],
},
{
title: '图片数据集',
key: '0-1',
icon: imageIcon,
switcherIcon: switcherIcon,
type: 'image',
children: [
{
title: '图片1.jpg',
key: '0-1-0',
icon: imageIcon,
type: 'image',
},
{
title: '图片2.png',
key: '0-1-1',
icon: imageIcon,
type: 'image',
},
],
},
{
title: '通用数据集',
key: '0-2',
icon: datasetsIcon,
type: 'dataset',
},
],
},
]);
const normalizeExt = (ext?: string): string => {
if (typeof ext !== 'string') return '';
return ext.trim().replace(/^\./, '').toLowerCase();
};
const isFolderLike = (node: any): boolean => {
const ext = normalizeExt(node?.file_ext);
if (ext) {
return ext === 'folder';
}
const type = typeof node?.type === 'string' ? node.type.toLowerCase() : '';
if (type === 'folder' || type === 'directory') return true;
if (typeof node?.is_directory === 'boolean') return node.is_directory;
if (typeof node?.is_dir === 'boolean') return node.is_dir;
if (node?.folder_name || node?.children) return true;
return false;
};
const getNodeTitle = (node: any): string => (
node?.folder_name
?? node?.file_name
?? node?.name
?? node?.title
?? '未命名节点'
);
const getNodeIcon = (node: any, isFolder: boolean): string => {
if (isFolder) return folderIcon;
const type = typeof node?.type === 'string' ? node.type.toLowerCase() : '';
if (type === 'image') return imageIcon;
if (type === 'text') return textIcon;
const ext = normalizeExt(node?.file_ext);
if (IMAGE_EXTENSIONS.has(ext)) return imageIcon;
if (TEXT_EXTENSIONS.has(ext)) return textIcon;
return datasetsIcon;
};
const extractItems = (resp: any): any[] => {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (Array.isArray(resp?.items)) return resp.items;
if (Array.isArray(resp?.list)) return resp.list;
if (Array.isArray(resp?.data?.items)) return resp.data.items;
return [];
};
// 只加载当前层级的节点,不递归加载子节点
const buildTreeNodes = async (
kbId: string,
parentId: string,
): Promise<TreeNodeData[]> => {
const currentParent = String(parentId ?? '');
if (!currentParent) return [];
// 只请求一次当前层级的数据,不分页
const response = await getFolderList({
kb_id: kbId,
parent_id: currentParent,
page: 1,
pagesize: 1000
} as any);
const rawItems = extractItems(response);
const nodes: TreeNodeData[] = [];
for (let index = 0; index < rawItems.length; index += 1) {
const raw = rawItems[index];
const keySource = raw?.id ?? raw?.file_id ?? raw?.key ?? raw?.folder_id ?? `${currentParent}-${index}`;
const nodeKey = String(keySource);
const isFolder = isFolderLike(raw);
// 只显示文件夹
if (!isFolder) {
continue;
}
// 文件夹节点初始不加载子节点isLeaf设为false表示可能有子节点
nodes.push({
key: nodeKey,
title: getNodeTitle(raw),
icon: getNodeIcon(raw, isFolder),
switcherIcon: isFolder ? switcherIcon : undefined,
type: isFolder ? 'folder' : (typeof raw?.type === 'string' ? raw.type : normalizeExt(raw?.file_ext) || 'file'),
isLeaf: false, // 文件夹节点初始设为false表示可能有子节点需要展开时加载
children: undefined, // 初始不加载子节点
});
}
return nodes;
};
const FolderTree: FC<FolderTreeProps> = ({
knowledgeBaseId,
onSelect,
onExpand,
multiple,
className,
style,
refreshKey = 0,
onRootLoad,
}) => {
const [treeData, setTreeData] = useState<TreeNodeData[]>([]);
// 更新树节点数据的辅助函数
const updateTreeData = (nodes: TreeNodeData[], key: Key, children: TreeNodeData[]): TreeNodeData[] => {
return nodes.map((node) => {
if (node.key === key) {
return {
...node,
children: children.length > 0 ? children : undefined,
isLeaf: children.length === 0,
};
}
if (node.children) {
return {
...node,
children: updateTreeData(node.children, key, children),
};
}
return node;
});
};
// 加载根节点
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!knowledgeBaseId) {
setTreeData([]);
return;
}
try {
const nodes = await buildTreeNodes(knowledgeBaseId, knowledgeBaseId);
if (!cancelled) {
setTreeData(nodes);
if (onRootLoad) {
onRootLoad(nodes.length > 0 ? nodes : null);
}
}
} catch (e) {
console.error('加载文件夹树失败:', e);
if (!cancelled) {
const fallback = buildMockTreeData();
setTreeData(fallback);
if (onRootLoad) {
onRootLoad(fallback.length > 0 ? fallback : null);
}
}
}
};
load();
return () => {
cancelled = true;
};
}, [knowledgeBaseId, refreshKey]);
// 懒加载子节点 - 只在展开时加载
const onLoadData = async (node: any) => {
const { key } = node;
// 如果已经加载过子节点,不再重复加载
if (node.children !== undefined) {
return Promise.resolve();
}
try {
// 使用节点的 key 作为 parent_id 加载子文件夹
const children = await buildTreeNodes(knowledgeBaseId, String(key));
setTreeData((prevData) => updateTreeData(prevData, key, children));
} catch (e) {
console.error('加载子节点失败:', e);
// 加载失败时,将该节点标记为叶子节点(没有子节点)
setTreeData((prevData) => updateTreeData(prevData, key, []));
}
};
const treeNodes = useMemo(() => transformTreeData(treeData), [treeData]);
return (
<DirectoryTree
multiple={multiple}
className={className}
style={style}
onSelect={onSelect}
onExpand={onExpand}
loadData={onLoadData}
treeData={treeNodes}
/>
);
};
export default FolderTree;

View File

@@ -0,0 +1,44 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-18 16:27:41
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-19 19:59:36
*/
import { Divider } from 'antd';
export interface InfoItem {
key: string;
label: string;
value: string | number | undefined;
icon?: string;
}
interface InfoPanelProps {
title: string;
items: InfoItem[];
className?: string;
}
const InfoPanel = ({ title, items, className = '' }: InfoPanelProps) => {
return (
<div className={`rb:w-full ${className}`}>
<h2 className="rb:text-lg rb:font-medium">{title}</h2>
<Divider />
<div className='rb:flex rb:flex-col rb:items-start rb:gap-6'>
{items.map((item) => (
<div key={item.key} className='rb:flex rb:w-full rb:items-start rb:justify-start rb:gap-2'>
{item.icon && <img src={item.icon} className='rb:size-4 rb:mt-[2px]' alt="" />}
<div className='rb:flex rb:flex-col rb:text-left rb:gap-2'>
<span className='rb:text-gray-500 rb:text-sm'>{item.label}</span>
<span className='rb:text-gray-800'>{item.value ?? '-'}</span>
</div>
</div>
))}
</div>
</div>
);
};
export default InfoPanel;

View File

@@ -0,0 +1,156 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Input, message, Tabs } from 'antd';
import { useTranslation } from 'react-i18next';
import RbModal from '@/components/RbModal';
import RbMarkdown from '@/components/Markdown';
const { TextArea } = Input;
export interface InsertModalRef {
handleOpen: (documentId: string, initialContent?: string, chunkId?: string) => void;
handleClose: () => void;
}
interface InsertModalProps {
onInsert?: (documentId: string, content: string, chunkId?: string) => Promise<boolean>;
onSuccess?: () => void;
}
const InsertModal = forwardRef<InsertModalRef, InsertModalProps>(({ onInsert, onSuccess }, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [documentId, setDocumentId] = useState<string>('');
const [content, setContent] = useState<string>('');
const [chunkId, setChunkId] = useState<string | undefined>(undefined);
const [isEditMode, setIsEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<string>('edit');
const handleOpen = (docId: string, initialContent?: string, chunkIdParam?: string) => {
setDocumentId(docId);
setContent(initialContent || '');
setChunkId(chunkIdParam);
setIsEditMode(!!initialContent);
setVisible(true);
};
const handleClose = () => {
setVisible(false);
setContent('');
setDocumentId('');
setChunkId(undefined);
setIsEditMode(false);
setActiveTab('edit');
};
const handleOk = async () => {
if (!content.trim()) {
message.warning(t('knowledgeBase.pleaseEnterContent') || '请输入内容');
return;
}
if (!documentId) {
message.error(t('knowledgeBase.documentIdRequired') || '文档ID不能为空');
return;
}
setLoading(true);
try {
if (onInsert) {
const success = await onInsert(documentId, content.trim(), chunkId);
if (success) {
const successMsg = isEditMode
? (t('knowledgeBase.updateSuccess') || '更新成功')
: (t('knowledgeBase.insertSuccess') || '插入成功');
message.success(successMsg);
handleClose();
// 只有插入模式才调用 onSuccess编辑模式已在 handleInsertContent 中直接更新列表)
if (!isEditMode) {
onSuccess?.();
}
} else {
const errorMsg = isEditMode
? (t('knowledgeBase.updateFailed') || '更新失败')
: (t('knowledgeBase.insertFailed') || '插入失败');
message.error(errorMsg);
}
}
} catch (error) {
console.error('操作失败:', error);
const errorMsg = isEditMode
? (t('knowledgeBase.updateFailed') || '更新失败')
: (t('knowledgeBase.insertFailed') || '插入失败');
message.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose,
}));
// 构建标签页项目content 为空或新增时不显示预览
const tabItems = [
{
key: 'edit',
label: t('knowledgeBase.edit') || '编辑',
children: (
<TextArea
value={content}
onChange={handleContentChange}
placeholder={t('knowledgeBase.insertContentPlaceholder') || '请输入内容...'}
rows={10}
maxLength={10000}
showCount
autoFocus
/>
),
},
];
// 只有在编辑模式且有内容时才显示预览标签页
if (isEditMode && content) {
tabItems.push({
key: 'preview',
label: t('knowledgeBase.preview') || '预览',
children: (
<div className='rb:border rb:border-[#D9D9D9] rb:rounded rb:p-4 rb:min-h-[280px] rb:max-h-[400px] rb:overflow-y-auto rb:bg-white'>
<RbMarkdown content={content} showHtmlComments={true} />
</div>
),
});
}
return (
<RbModal
title={isEditMode
? (t('knowledgeBase.editContent') || '编辑内容')
: (t('knowledgeBase.insertContent') || '插入内容')
}
open={visible}
onCancel={handleClose}
onOk={handleOk}
confirmLoading={loading}
okText={t('common.confirm') || '确认'}
cancelText={t('common.cancel') || '取消'}
width={600}
>
<div className='rb:flex rb:flex-col rb:gap-4'>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</div>
</RbModal>
);
});
export default InsertModal;

View File

@@ -0,0 +1,199 @@
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import { Form, Input, Select, Button, InputNumber } from 'antd';
import { useTranslation } from 'react-i18next';
import type { RecallTestDrawerRef, RecallTestData, RecallTestParams } from '../types';
// import refreshIcon from '@/assets/images/knowledgeBase/refresh-blue.png';
import RecallTestResult from './RecallTestResult';
import { reChunks, getRetrievalModeType } from '../service';
import { hybrid } from 'react-syntax-highlighter/dist/esm/styles/hljs';
const { TextArea } = Input;
interface RetrievalModeOption {
label: string;
value: boolean;
}
const RecallTest = forwardRef<RecallTestDrawerRef>(({},ref) => {
const [form] = Form.useForm();
const { t } = useTranslation();
const [data, setData] = useState<RecallTestData[]>([]);
const [knowledgeBaseId, setKnowledgeBaseId] = useState<string>('');
const [loading, setLoading] = useState(false);
const [retrieveType, setRetrieveType] = useState<string>('hybrid');
const [retrievalModeOptions, setRetrievalModeOptions] = useState<RetrievalModeOption[]>([
{ label: t('knowledgeBase.hybrid'), value: true },
{ label: t('knowledgeBase.vector'), value: false },
]);
// 获取检索模式选项
useEffect(() => {
fetchRetrievalModeOptions();
}, []);
const fetchRetrievalModeOptions = async () => {
try {
const response = await getRetrievalModeType();
if (response && Array.isArray(response)) {
// 将 API 返回的数据转换为选项格式
const options = response.map((item: any) => {
// 支持多种数据格式
let label = t(`knowledgeBase.${item}`) + ' ' + t(`knowledgeBase.retrieve`);
let value = item;
return { label, value };
});
if (options.length > 0) {
setRetrievalModeOptions(options);
}
}
} catch (error) {
console.error('获取检索模式选项失败:', error);
// 保持默认选项
}
};
const handleOpen = (kbId?: string) => {
console.log('RecallTest - handleOpen called with kbId:', kbId);
setKnowledgeBaseId(kbId || '');
form.resetFields();
setData([]);
setRetrieveType('hybrid'); // 重置为默认值
// 确保表单字段也设置为默认值
form.setFieldsValue({ retrieve_type: 'hybrid' });
}
const fetchData = (params: RecallTestParams) => {
if (loading) return;
setLoading(true);
console.log('params', params);
reChunks(params)
.then((res) => {
const response = res as RecallTestData[] ;
setData(response || [])
})
.finally(() => {
setLoading(false);
});
}
const handleStartTest = () => {
form.validateFields().then((values) => {
const params: RecallTestParams = {
query: values.query || '',
kb_ids: knowledgeBaseId ? [knowledgeBaseId] : [],
similarity_threshold: values.similarity_threshold || 0.2,
vector_similarity_weight: values.vector_similarity_weight || 0.3,
top_k: values.top_k || 1024,
// hybrid: values.retrieve_type !== hybrid ? true : false,
retrieve_type: retrieveType,
};
console.log('RecallTest - params:', params);
fetchData(params);
}).catch((error) => {
console.error('表单验证失败:', error);
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<div className='rb:w-full rb:h-full rb:flex rb:flex-col rb:overflow-hidden'>
<div className='rb:flex-shrink-0'>
<div className='rb:flexx rb:mb-2 rb:items-center rb:justify-between'>
<span className='rb:font-medium'>{ t('knowledgeBase.testQuestion')}</span>
{/* <div className='rb:flex rb:items-center rb:justify-end'>
<img src={refreshIcon} alt="refresh" className='rb:w-4 rb:h-4 rb:mr-2' />
<span className='rb:text-[#155eef]'>{ t('knowledgeBase.loadSampleQuestions')}</span>
</div> */}
</div>
<Form form={form} >
<Form.Item name="query">
<TextArea rows={4} placeholder={t('knowledgeBase.testQuestionPlaceholder')}/>
</Form.Item>
<div className='rb:grid rb:grid-cols-2 rb:gap-x-4'>
<Form.Item
name="retrieve_type"
label={t('knowledgeBase.retrieveMode')}
initialValue="hybrid"
>
<Select
options={retrievalModeOptions}
placeholder={t('knowledgeBase.retrieveMode')}
onChange={(value) => setRetrieveType(value)}
/>
</Form.Item>
<Form.Item name="top_k" label={t('knowledgeBase.recallQuantity')}>
<InputNumber
placeholder='1 ~ 1024'
min={1}
max={1024}
style={{ width: '100%' }}
/>
</Form.Item>
{/* 当 retrieve_type = semantic 或 hybrid 时显示 */}
{(retrieveType === 'semantic' || retrieveType === 'hybrid') && (
<Form.Item name="similarity_threshold" label={t('knowledgeBase.similarityThreshold')}>
<Select
options={[
{ label: '0.1', value: 0.1 },
{ label: '0.2', value: 0.2 },
{ label: '0.3', value: 0.3 },
{ label: '0.4', value: 0.4 },
{ label: '0.5', value: 0.5 },
{ label: '0.6', value: 0.6 },
{ label: '0.7', value: 0.7 },
{ label: '0.8', value: 0.8 },
{ label: '0.9', value: 0.9 },
{ label: '1.0', value: 1.0 },
]}
placeholder={t('knowledgeBase.similarityThreshold')}
/>
</Form.Item>
)}
{/* 当 retrieve_type = participle 或 hybrid 时显示 */}
{(retrieveType === 'participle' || retrieveType === 'hybrid') && (
<Form.Item name="vector_similarity_weight" label={t('knowledgeBase.semanticSimilarity')}>
<Select
options={[
{ label: '0.1', value: 0.1 },
{ label: '0.2', value: 0.2 },
{ label: '0.3', value: 0.3 },
{ label: '0.4', value: 0.4 },
{ label: '0.5', value: 0.5 },
{ label: '0.6', value: 0.6 },
{ label: '0.7', value: 0.7 },
{ label: '0.8', value: 0.8 },
{ label: '0.9', value: 0.9 },
{ label: '1.0', value: 1.0 },
]}
placeholder={t('knowledgeBase.semanticSimilarity')}
/>
</Form.Item>
)}
{/* <Form.Item name="hybrid" valuePropName="checked" initialValue={true} label={t('knowledgeBase.hybrid') || 'Hybrid'}>
<Switch checkedChildren={t('common.yes') || 'Yes'} unCheckedChildren={t('common.no') || 'No'} />
</Form.Item> */}
<Form.Item>
<Button type="primary" onClick={handleStartTest} loading={loading}>{ t('knowledgeBase.startTesting')}</Button>
</Form.Item>
</div>
{/* <div className='rb:flex rb:items-center rb:justify-end'>
</div> */}
</Form>
</div>
<div className='rb:flex-1 rb:overflow-y-auto rb:min-h-0'>
<RecallTestResult data={data} showEmpty={true} />
</div>
</div>
);
});
export default RecallTest;

View File

@@ -0,0 +1,60 @@
import { forwardRef, useImperativeHandle, useState, useRef, useLayoutEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import RbDrawer from '@/components/RbDrawer';
import type { RecallTestDrawerRef } from '../types';
import RecallTest from './RecallTest';
const RecallTestDrawer = forwardRef<RecallTestDrawerRef>(({},ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const recallTestRef = useRef<any>(null);
const pendingKbIdRef = useRef<string | undefined>(undefined);
const shouldCallHandleOpenRef = useRef(false);
// 调用 RecallTest 的 handleOpen 方法
const callRecallTestHandleOpen = useCallback(() => {
if (recallTestRef.current && shouldCallHandleOpenRef.current) {
recallTestRef.current.handleOpen(pendingKbIdRef.current);
shouldCallHandleOpenRef.current = false;
}
}, []);
const handleOpen = (kbId?: string) => {
pendingKbIdRef.current = kbId;
shouldCallHandleOpenRef.current = true;
setOpen(true);
}
// 当 Drawer 打开时,尝试调用 handleOpen
useLayoutEffect(() => {
if (open) {
callRecallTestHandleOpen();
}
}, [open, callRecallTestHandleOpen]);
// 使用回调 ref 确保在组件挂载后立即调用
const setRecallTestRef = useCallback((node: any) => {
recallTestRef.current = node;
if (open && shouldCallHandleOpenRef.current) {
callRecallTestHandleOpen();
}
}, [open, callRecallTestHandleOpen]);
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbDrawer
title={t('knowledgeBase.recallTest')}
open={open}
onClose={() => setOpen(false)}
>
<RecallTest ref={setRecallTestRef} />
</RbDrawer>
);
});
export default RecallTestDrawer;

View File

@@ -0,0 +1,187 @@
/*
* @Description: 滚动列表
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-18 16:19:58
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-29 19:08:40
*/
import { FileOutlined, FieldTimeOutlined, EditOutlined } from '@ant-design/icons';
import { Skeleton } from 'antd';
import { useTranslation } from 'react-i18next';
import type { RecallTestData } from '../types';
import { NoData } from './noData';
import { formatDateTime } from '@/utils/format';
import InfiniteScroll from 'react-infinite-scroll-component';
import RbMarkdown from '@/components/Markdown';
interface RecallTestResultProps {
data: RecallTestData[];
showEmpty?: boolean;
hasMore?: boolean;
loadMore?: () => void;
loading?: boolean;
scrollableTarget?: string;
editable?: boolean; // 是否可编辑
onItemClick?: (item: RecallTestData, index: number) => void; // 点击项的回调
}
const RecallTestResult = ({
data,
showEmpty = true,
hasMore = false,
loadMore,
loading = false,
scrollableTarget,
editable = false,
onItemClick,
}: RecallTestResultProps) => {
const { t } = useTranslation();
const handleItemClick = (e: React.MouseEvent, item: RecallTestData, index: number) => {
// 检查点击的是否是图片或图片相关元素
const target = e.target as HTMLElement;
// 检查是否点击了图片本身、图片的容器、预览层、关闭按钮或 SVG 图标
if (
target.tagName === 'IMG' ||
target.tagName === 'SVG' || // SVG 图标
target.tagName === 'PATH' || // SVG 路径
target.closest('.ant-image') ||
target.closest('.ant-image-preview') ||
target.closest('.ant-image-preview-wrap') ||
target.closest('.ant-image-preview-operations') ||
target.closest('.anticon') || // Ant Design 图标
target.classList.contains('ant-image-img') ||
target.classList.contains('ant-image-mask') ||
target.classList.contains('ant-image-preview-close') ||
target.classList.contains('anticon')
) {
return;
}
if (editable && onItemClick) {
onItemClick(item, index);
}
};
// 根据分数获取颜色类名
const getScoreColorClass = (score: number): string => {
const percentage = score * 100;
if (percentage >= 90) {
return 'rb:text-[#155EEF]';
} else if (percentage >= 80) {
return 'rb:text-[#369F21]';
} else {
return 'rb:text-[#FF5D34]';
}
};
if (data.length === 0 && showEmpty) {
return (
<NoData
title={t('knowledgeBase.recallTestUnStart')}
subTitle={t('knowledgeBase.recallTestUnStartSubTitle')}
/>
);
}
if (data.length === 0) {
return null;
}
const renderContent = () => (
<div className='rb:flex rb:flex-col rb:mt-4'>
{data.map((item, index) => {
const score = item.metadata?.score ?? 1;
const scorePercentage = score * 100;
const colorClass = getScoreColorClass(score);
const showScore = item.metadata?.score !== null && item.metadata?.score !== undefined;
return (
<div
key={`${item.metadata?.sort_id || index}-${index}`}
className={`rb:flex rb:flex-col rb:mb-4 rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:p-4 rb:pt-2 rb:pb-3 rb:relative rb:group ${editable ? 'rb:cursor-pointer rb:transition-all hover:rb:border-[#155EEF] hover:rb:shadow-md' : ''}`}
onClick={(e) => handleItemClick(e, item, index)}
>
{editable && (
<div className='rb:absolute rb:top-2 rb:right-2 rb:opacity-0 group-hover:rb:opacity-100 rb:transition-opacity'>
<EditOutlined className='rb:text-[#155EEF] rb:text-base' />
</div>
)}
<div className='rb:flex rb:items-center rb:justify-between'>
{showScore && (
<span className={`${colorClass} rb:text-xl rb:font-semibold`}>
{scorePercentage.toFixed(1)}% {t('knowledgeBase.similarity')}
</span>
)}
<div className={`rb:flex rb:mt-2 rb:flex-col rb:items-end rb:justify-end rb:gap-1 ${!showScore ? 'rb:w-full' : ''}`}>
<span className='rb:text-gray-800'>
<FileOutlined /> {item.metadata?.file_name || '-'}
</span>
<span className='rb:text-gray-500 rb:text-xs rb:bg-[#F0F3F8] rb:px-1 rb:py-[2px] rb:rounded'>
chunk_{item.metadata?.sort_id || index}
</span>
</div>
</div>
<div className='rb:flex rb:text-left rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:rounded-lg rb:mt-2'>
<div className='rb:text-gray-800 rb:text-sm rb:whitespace-pre-wrap rb:break-words rb:w-full'>
<RbMarkdown content={item.page_content} showHtmlComments={true} />
</div>
</div>
{item.metadata?.file_created_at && (
<div className='rb:flex rb:items-center rb:justify-start rb:mt-3'>
<span className='rb:text-gray-500 rb:text-xs'>
<FieldTimeOutlined /> {formatDateTime(item.metadata.file_created_at)}
</span>
</div>
)}
</div>
);
})}
{loading && (
<div className='rb:mb-4'>
<Skeleton active paragraph={{ rows: 3 }} />
</div>
)}
</div>
);
// 如果提供了 loadMore 和 hasMore使用 InfiniteScroll
if (loadMore && hasMore !== undefined) {
return (
<div className='rb:flex rb:h-full rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>
<InfiniteScroll
dataLength={data.length}
next={loadMore}
hasMore={hasMore}
loader={<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />}
scrollableTarget={scrollableTarget}
>
{renderContent()}
</InfiniteScroll>
</div>
);
}
// 否则使用普通渲染
return (
<div className='rb:flex rb:flex-col'>
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2'>
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
<span className='rb:text-gray-500 rb:text-xs rb:pt-[2px]'>
(<span className='rb:text-[#155EEF]'>{data.length}</span> results)
</span>
</div>
{renderContent()}
</div>
);
};
export default RecallTestResult;

View File

@@ -0,0 +1,137 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-10 18:52:55
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-29 12:29:31
*/
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import { message } from 'antd';
import type { ShareModalRef, ShareModalRefProps, KnowledgeBase} from '../types';
import RbModal from '@/components/RbModal'
// import betchControlIcon from '@/assets/images/knowledgeBase/betch-control.png';
import kbIcon from '@/assets/images/knowledgeBase/knowledge-management.png';
// import robotIcon from '@/assets/images/knowledgeBase/robot.png';
import { updateKnowledgeBase, getWorkspaceAuthorizationList } from '../service';
import { NoData } from './noData';
import type { ListQuery, ShareSpaceModalRef } from '../types';
import { formatDateTime } from '@/utils/format';
import ShareSpaceModal from './ShareSpaceModal'
const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare: onShare }, ref) => {
const { t } = useTranslation();
const shareSpaceModalRef = useRef<ShareSpaceModalRef>(null);
const [messageApi, contextHolder] = message.useMessage();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [curIndex, setCurIndex] = useState(9999);
const [query, setQuery] = useState<ListQuery>({});
const [kbId, setKbId] = useState<string>('');
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setCurIndex(9999);
setLoading(false)
setVisible(false);
};
const handleOpen = (kb_id?: string,knowledgeBase?: KnowledgeBase | null) => {
setKbId(kb_id ?? '');
setKnowledgeBase(knowledgeBase ?? null);
setVisible(true);
getShareSpaceList(kb_id || '')
// getSpaceListFn()
};
const getShareSpaceList = async(id: string) => {
try{
const response = await getWorkspaceAuthorizationList(id)
setSpaceList(response?.items as any[]);
} catch (error) {
messageApi.error(t('knowledgeBase.shareFailed'));
}
}
const handleShare = async() => {
const workspaceIds = spaceList
.map(item => item.target_kb?.workspace_id)
.filter(Boolean)
.join(',');
console.log('Workspace IDs:', workspaceIds);
shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds);
// 分享后关闭弹窗
handleClose();
}
const handleChange = (checked: boolean, item: any) => {
// 打开/关闭分享出去的数据库
console.log('Switch changed:', checked, item);
updateKnowledgeBase(item.target_kb?.id, {
status: checked ? 1 : 2
}).then(() => {
messageApi.success(t('knowledgeBase.shareSuccess'));
getShareSpaceList(kbId);
}).catch(() => {
messageApi.error(t('knowledgeBase.shareFailed'));
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose,
handleShare
}));
return (
<>
{contextHolder}
<RbModal
title={t('knowledgeBase.toWorkspace')}
open={visible}
onCancel={handleClose}
okText={t('knowledgeBase.share')}
onOk={handleShare}
confirmLoading={loading}
>
<div className='rb:flex rb:flex-col rb:text-left'>
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareTitle')}</h4>
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareNote')}</span>
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
{spaceList.length === 0 && (
<NoData />
)}
{spaceList.map((item,index) => (
<div key={index}
className={`rb:flex rb:items-center rb:justify-between rb:border-gray-200 rb:gap-2 rb:rounded-lg rb:p-4 rb:border`}
>
<div className='rb:flex rb:items-center rb:gap-2'>
<img src={item.icon || kbIcon} className='rb:size-[20px]' />
<div className='rb:flex rb:flex-col rb:text-left rb:gap-1'>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{item.target_workspace?.name}</span>
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.authorizedPerson')}:{item.shared_user?.username} {formatDateTime((item.target_workspace?.created_at || 0))}</span>
</div>
</div>
<div>
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={item.target_kb?.status === 1} onChange={(checked) => handleChange(checked, item)} />
</div>
</div>
))}
</div>
</div>
</RbModal>
<ShareSpaceModal
ref={shareSpaceModalRef}
/>
</>
);
});
export default ShareModal;

View File

@@ -0,0 +1,127 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-11-10 18:52:55
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-25 17:46:36
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import { message } from 'antd';
import type { ShareModalRef, ShareModalRefProps, KnowledgeBase} from '../types';
import RbModal from '@/components/RbModal'
// import betchControlIcon from '@/assets/images/knowledgeBase/betch-control.png';
import kbIcon from '@/assets/images/knowledgeBase/knowledge-management.png';
// import robotIcon from '@/assets/images/knowledgeBase/robot.png';
import { getSpaceList, shareKnowledgeBase } from '../service';
import { NoData } from './noData';
import type { SpaceItem } from '../types';
import { formatDateTime } from '@/utils/format';
const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare: onShare }, ref) => {
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [curIndex, setCurIndex] = useState(9999);
const [kbId, setKbId] = useState<string>('');
const [spaceIds, setSpaceIds] = useState<string>('');
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setCurIndex(9999);
setLoading(false)
setVisible(false);
};
const handleOpen = (kb_id?: string,knowledgeBase?: KnowledgeBase | null, spaceIds?:string) => {
setKbId(kb_id ?? '');
setSpaceIds(spaceIds ?? '')
setKnowledgeBase(knowledgeBase ?? null);
setVisible(true);
getSpaceListFn(spaceIds ?? '')
};
const getSpaceListFn = async (ids:string) => {
const response = await getSpaceList();
const filteredItems = response.items.filter(item => !ids.includes(item.id));
setSpaceList(filteredItems as SpaceItem[]);
}
const handleShare = async() => {
// 获取所有 checked 为 true 的数据
const checkedItems = spaceList.filter(item => item.is_active);
// 获取当前选中的项curIndex 对应的数据)
const selectedItem = curIndex !== 9999 ? spaceList[curIndex] : null;
const payload = {
source_kb_id: kbId ?? '',
target_workspace_id: selectedItem?.id ?? '',
}
const respose = await shareKnowledgeBase(payload)
if(respose){
messageApi.success(t('knowledgeBase.shareSuccess'));
}else{
messageApi.error(t('knowledgeBase.shareFailed'));
}
// 调用父组件传递的回调函数,传递选中的数据
onShare?.({
checkedItems,
selectedItem
});
// 分享后关闭弹窗
handleClose();
}
const handleClick = (index: number, checked: boolean) => {
if (!checked) return;
setCurIndex(index);
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose,
handleShare
}));
return (
<>
{contextHolder}
<RbModal
title={t('knowledgeBase.toWorkspace')}
open={visible}
onCancel={handleClose}
okText={t('knowledgeBase.share')}
onOk={handleShare}
confirmLoading={loading}
>
<div className='rb:flex rb:flex-col rb:text-left'>
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareTitle')}</h4>
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareNote')}</span>
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
{spaceList.length === 0 && (
<NoData />
)}
{spaceList.map((item,index) => (
<div key={index}
className={`rb:flex rb:items-center rb:justify-between ${curIndex === index ? 'rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF]' : 'rb:border-gray-200'} ${item.is_active ? 'rb:cursor-pointer rb:hover:bg-[rgba(21,94,239,0.06)] rb:hover:border-[#155EEF]' : 'rb:cursor-not-allowed rb:bg-[#F9F9F9]'} rb:gap-2 rb:rounded-lg rb:p-4 rb:border`}
onClick={item.is_active ? () => handleClick(index, item.is_active) : undefined}
>
<div className='rb:flex rb:items-center rb:gap-2'>
<img src={item.icon || kbIcon} className='rb:size-[20px]' />
<div className='rb:flex rb:flex-col rb:text-left rb:gap-1'>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{item.name}</span>
</div>
</div>
</div>
))}
</div>
</div>
</RbModal>
</>
);
});
export default ShareModal;

View File

@@ -0,0 +1,16 @@
import blankImage from '@/assets/images/knowledgeBase/blankImage.png';
interface NoDataProps {
title?: string;
subTitle?: string;
image?: string;
}
export const NoData = ({ title = 'No data', subTitle, image = blankImage }: NoDataProps) => {
return (
<div className='rb:flex rb:flex-col rb:items-center rb:justify-center rb:mt-9'>
<img src={image} alt="blank" className='rb:w-[200px] rb:h-[200px]' />
<span className='rb:text-lg'>{title}</span>
{subTitle && <span className='rb:text-gray-500 rb:mt-2 rb:text-xs'>{subTitle}</span>}
</div>
);
};

View File

@@ -0,0 +1,71 @@
/**
* 文档分隔符选项配置
*/
export interface DelimiterOption {
label: string;
value: string;
description?: string;
displayValue?: string; // 用于显示的值(如果和实际值不同)
}
export const DELIMITER_OPTIONS: DelimiterOption[] = [
{
label: '不设置',
value: '',
description: '不使用分隔符(不传递该参数)',
},
{
label: '1个换行符',
value: '\n',
displayValue: '\\n',
description: '使用单个换行符作为分隔符',
},
{
label: '2个换行符',
value: '\n\n',
displayValue: '\\n\\n',
description: '使用两个换行符作为分隔符',
},
{
label: '句号',
value: '。',
description: '使用句号作为分隔符',
},
{
label: '感叹号',
value: '',
description: '使用感叹号作为分隔符',
},
{
label: '问号',
value: '',
description: '使用问号作为分隔符',
},
{
label: '分号',
value: '',
description: '使用分号作为分隔符',
},
{
label: '=====',
value: '=====',
description: '使用五个等号作为分隔符',
},
{
label: '自定义',
value: 'custom',
description: '自定义分隔符',
},
];
// 获取分隔符的显示文本
export const getDelimiterDisplay = (value: string): string => {
const option = DELIMITER_OPTIONS.find(opt => opt.value === value);
return option?.displayValue || option?.label || value;
};
// 判断是否为自定义分隔符
export const isCustomDelimiter = (value: string): boolean => {
return value === 'custom' || !DELIMITER_OPTIONS.some(opt => opt.value === value);
};

View File

@@ -0,0 +1,72 @@
import { useEffect, useState, type FC } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { request } from '@/utils/request';
import type { KnowledgeBase } from './types';
const Datasets: FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null);
useEffect(() => {
if (id) {
fetchKnowledgeBaseDetail(id);
}
}, [id]);
const fetchKnowledgeBaseDetail = (knowledgeBaseId: string) => {
setLoading(true);
request.get(`/knowledgeBase/${knowledgeBaseId}`)
.then((res: any) => {
setKnowledgeBase(res.data || res);
})
.finally(() => {
setLoading(false);
});
};
const handleBack = () => {
navigate('/knowledge-base');
};
if (loading) {
return <div>...</div>;
}
if (!knowledgeBase) {
return <div></div>;
}
return (
<div className="rb:p-6">
<div className="rb:mb-4">
<Button
icon={<ArrowLeftOutlined />}
onClick={handleBack}
>
</Button>
</div>
<div className="rb:mb-4">
<h1 className="rb:text-2xl rb:font-bold">{knowledgeBase.name}</h1>
<p className="rb:text-gray-600 rb:mt-2">{knowledgeBase.description || t('knowledgeBase.noDescription')}</p>
</div>
<div className="rb:bg-white rb:p-4 rb:rounded">
<h2 className="rb:text-lg rb:font-semibold rb:mb-4">{t('knowledgeBase.datasets')}</h2>
{/* TODO: 添加数据集列表 */}
<div>{t('knowledgeBase.noDataSets')}</div>
</div>
</div>
);
};
export default Datasets;

View File

@@ -0,0 +1,4 @@
.textBg:hover,
.textBg.active{
background-color: rgba(21, 94, 239, 0.08);
}

View File

@@ -0,0 +1,464 @@
import { useEffect, useState, useRef, useMemo, type FC } from 'react';
import { Row, Col, Button, Dropdown, Modal, message, Tooltip } from 'antd'
import type { MenuProps } from 'antd';
import { EllipsisOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
import generalIcon from '@/assets/images/knowledgeBase/datasets.png';
import webIcon from '@/assets/images/knowledgeBase/general.png';
import tpIcon from '@/assets/images/knowledgeBase/text.png';
import type { KnowledgeBaseListItem, CreateModalRef, KnowledgeBaseListResponse, ListQuery } from './types'
import CreateModal from './components/CreateModal'
import RbCard from '@/components/RbCard'
import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty'
import { getKnowledgeBaseList, getModelList, getModelTypeList, deleteKnowledgeBase, getKnowledgeBaseTypeList } from './service'
const { confirm } = Modal;
import InfiniteScroll from 'react-infinite-scroll-component';
type ModelMenuInfo = {
menu: NonNullable<MenuProps['items']>;
summary: string[];
};
const KnowledgeBaseManagement: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<KnowledgeBaseListItem[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [query, setQuery] = useState<ListQuery>({
orderby:'created_at',
desc:true,
})
const [modelTypes, setModelTypes] = useState<string[]>([]);
const [modelMenus, setModelMenus] = useState<Record<string, ModelMenuInfo>>({});
const [knowledgeBaseTypes, setKnowledgeBaseTypes] = useState<string[]>([]);
const modelListCache = useRef<Record<string, string>>({});
const modalRef = useRef<CreateModalRef>(null)
const [messageApi, contextHolder] = message.useMessage();
// 生成下拉菜单项(根据当前 item
const getOptMenuItems = (item: KnowledgeBaseListItem): MenuProps['items'] => {
const items: NonNullable<MenuProps['items']> = [];
// 当权限为 share 时,不显示编辑按钮
if (item.permission_id !== 'share') {
items.push({
key: '1',
label: t('knowledgeBase.edit'),
onClick: () => {
handleEdit(item);
},
});
}
items.push({
key: '2',
label: t('knowledgeBase.delete'),
onClick: () => {
handleDelete(item);
},
});
return items;
};
// 根据类型获取图标
const getTypeIcon = (type: string) => {
const normalized = (type || '').toLowerCase();
switch (normalized) {
case 'general':
return generalIcon;
case 'folder':
return folderIcon;
case 'web':
return webIcon;
case 'third-party':
case 'tp':
return tpIcon;
default:
return generalIcon;
}
};
// 根据类型获取翻译 key
const getTypeLabelKey = (type: string) => {
const normalized = (type || '').toLowerCase();
switch (normalized) {
case 'general':
return 'knowledgeBase.general';
case 'folder':
return 'knowledgeBase.folder';
case 'web':
return 'knowledgeBase.web';
case 'third-party':
case 'tp':
return 'knowledgeBase.tp';
default:
return `knowledgeBase.${normalized}`;
}
};
// 处理创建
const handleCreate = (type?: string) => {
modalRef?.current?.handleOpen(null, type)
}
// 动态生成 createItems
const createItems: MenuProps['items'] = useMemo(() => {
return knowledgeBaseTypes.map((type, index) => ({
key: String(index + 1),
icon: <img src={getTypeIcon(type)} alt={type} style={{ width: 16, height: 16 }} />,
label: t(getTypeLabelKey(type.toLocaleLowerCase())),
onClick: () => {
handleCreate(type);
},
}));
}, [knowledgeBaseTypes, t]);
const typeToFieldKey = (type: string) => {
const normalized = (type || '').toLowerCase();
switch (normalized) {
case 'embedding':
return 'embedding_id';
case 'llm':
return 'llm_id';
case 'image2text':
return 'image2text_id';
case 'rerank':
case 'reranker':
return 'reranker_id';
case 'chat':
return 'chat_id';
default:
return `${normalized}_id`;
}
};
const formatData = (data: KnowledgeBaseListItem) => {
const keys: (keyof KnowledgeBaseListItem)[] = ['type', 'permission_id']
return keys.map(key => ({
key,
label: t(`knowledgeBase.${key}`),
children: key === 'permission_id'
? (data[key] === 'Private' || data[key] === 'private' ? t('knowledgeBase.private') : t('knowledgeBase.share'))
: String(data[key] || '-'),
}))
}
const fetchModelTypes = async () => {
try {
const response = await getModelTypeList();
setModelTypes(Array.isArray(response) ? [...response.filter(type => type !== 'chat'),'image2text'] : []);
} catch (error) {
console.error('Failed to fetch model types:', error);
setModelTypes([]);
}
};
const fetchModelList = async () => {
try {
const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 });
// 缓存模型列表,建立 id -> name 的映射
if (response?.items && Array.isArray(response.items)) {
const cache: Record<string, string> = {};
response.items.forEach((model: any) => {
if (model.id && model.name) {
cache[model.id] = model.name;
}
});
modelListCache.current = cache;
}
} catch (error) {
console.error('Failed to fetch model list:', error);
}
};
const fetchKnowledgeBaseTypes = async () => {
try {
let types = await getKnowledgeBaseTypeList();
types = types.filter(type => (type === 'General' )); //|| type === 'Folder'
//暂时未实现 ,过滤掉未实现
setKnowledgeBaseTypes(types);
} catch (error) {
console.error('Failed to fetch knowledge base types:', error);
setKnowledgeBaseTypes([]);
}
};
const getModelNameById = (id?: string | null) => {
if (!id) return '';
// 从模型列表缓存中获取模型名称
return modelListCache.current[id] || '';
};
const buildModelMenuForItem = (item: KnowledgeBaseListItem): ModelMenuInfo | null => {
const entries: { menuItem: NonNullable<MenuProps['items']>[number]; summary: string }[] = [];
const record = item as unknown as Record<string, unknown>;
for (const type of modelTypes) {
const curType = type === 'rerank' ? 'reranker' : type;
const fieldKey = typeToFieldKey(curType);
const modelId = record[fieldKey] as string | undefined;
if (!modelId) continue;
const modelName = getModelNameById(modelId);
if (!modelName) continue;
const typeLabel = t(`knowledgeBase.createForm.${fieldKey}`) || t(`knowledgeBase.${fieldKey}`) || type;
entries.push({
menuItem: {
key: `${fieldKey}_${modelId}`,
label: (
<span className="rb:text-gray-500 rb:text-[12px]">
{typeLabel}: {modelName}
</span>
),
},
summary: `${typeLabel}: ${modelName}`,
});
}
if (!entries.length) {
return null;
}
const header: NonNullable<MenuProps['items']>[number] = {
key: 'header',
label: (<span className='rb:font-medium'>{t('knowledgeBase.allModels')}</span>),
disabled: true,
};
const menuArray = [header, ...entries.map(({ menuItem }) => menuItem)] as NonNullable<MenuProps['items']>;
return {
menu: menuArray,
summary: entries.map(({ summary }) => summary),
};
};
const buildModelMenus = (items: KnowledgeBaseListItem[], isLoadMore: boolean = false) => {
const nextMenus: Record<string, ModelMenuInfo> = {};
items.forEach((item) => {
const result = buildModelMenuForItem(item);
if (result) {
nextMenus[item.id] = result;
}
});
if (isLoadMore) {
// 加载更多时,合并之前的菜单
setModelMenus(prev => ({ ...prev, ...nextMenus }));
} else {
// 首次加载或刷新时,替换所有菜单
setModelMenus(nextMenus);
}
};
const fetchData = async (pageNum: number = 1, isLoadMore: boolean = false) => {
if (!modelTypes.length) return;
if (loading) return;
console.log('fetchData called, pageNum:', pageNum, 'isLoadMore:', isLoadMore);
setLoading(true);
try {
const params = {
...query,
page: pageNum,
pagesize: 9,
orderby:'created_at',
desc:true,
}
const res = await getKnowledgeBaseList(undefined, params);
const response = res as KnowledgeBaseListResponse & { items?: KnowledgeBaseListItem[] };
console.log('API response:', response);
const list = response.items || [];
const curDatas = list.map((item: KnowledgeBaseListItem) => ({
...item,
descriptionItems: formatData(item),
}));
if (isLoadMore) {
setData(prev => [...prev, ...curDatas]);
} else {
setData(curDatas);
// 重置分页状态,确保从第一页开始
setPage(1);
}
// 更新是否有更多数据
const hasNext = response.page?.has_next ?? false;
console.log('hasNext:', hasNext, 'response.page:', response.page);
setHasMore(hasNext);
buildModelMenus(list, isLoadMore);
} catch (error) {
console.error('Failed to fetch knowledge base list:', error);
if (!isLoadMore) {
setData([]);
setModelMenus({});
setPage(1);
}
setHasMore(false);
} finally {
setLoading(false);
}
}
const loadMore = () => {
console.log('loadMore called, loading:', loading, 'hasMore:', hasMore, 'page:', page);
if (loading || !hasMore) return;
const nextPage = page + 1;
setPage(nextPage);
fetchData(nextPage, true);
}
// 创建一个稳定的刷新函数供子组件调用
const handleRefresh = () => {
fetchData(1, false);
}
const handleSearch = (value?: string) => {
setQuery((prev) => ({
...prev,
keywords: value,
}))
}
// 处理编辑
const handleEdit = (item: KnowledgeBaseListItem) => {
modalRef?.current?.handleOpen(item, item.type);
};
// 处理删除
const handleDelete = (item: KnowledgeBaseListItem) => {
confirm({
title: t('common.deleteWarning'),
content: t('common.deleteWarningContent', { content: item.name }),
onOk: () => {
deleteKnowledgeBase(item.id).then((res) => {
if (res) {
messageApi.success(t('common.deleteSuccess'));
fetchData(1, false);
}
});
},
onCancel: () => {
console.log('取消删除');
},
});
};
// 处理跳转详情
const handleToDetail = (knowledgeBase: KnowledgeBaseListItem) => {
// 根据权限类型跳转到不同的详情页
if (knowledgeBase.permission_id === 'Private' || knowledgeBase.permission_id === 'private') {
navigate(`/knowledge-base/${knowledgeBase.id}/private`)
} else {
navigate(`/knowledge-base/${knowledgeBase.id}/share`)
}
}
useEffect(() => {
fetchModelTypes();
fetchKnowledgeBaseTypes();
fetchModelList();
}, [])
useEffect(() => {
if (modelTypes.length) {
fetchData(1, false);
}
}, [modelTypes, query])
return (
<>
{contextHolder}
<div className="rb:flex rb:justify-between rb:px-2 rb:mb-4">
<SearchInput
placeholder={t('knowledgeBase.searchPlaceholder')}
onSearch={handleSearch}
style={{ width: '32.666%' }}
/>
<Dropdown menu={{ items: createItems }} trigger={['click']}>
<Button type="primary">+ {t('knowledgeBase.createKnowledgeBase')}</Button>
</Dropdown>
</div>
<div id="scrollableDiv" style={{ height: 'calc(100vh - 120px)', overflowY: 'auto', overflowX: 'hidden' }}>
<InfiniteScroll
dataLength={data.length}
next={loadMore}
hasMore={hasMore && !loading}
loader={<div className="rb:text-center rb:py-4">{t('common.loading')}</div>}
endMessage={
data.length > 0 ? (
<div className="rb:text-center rb:py-4 rb:text-gray-400">
{t('common.noMoreData')}
</div>
) : null
}
scrollThreshold={0.9}
scrollableTarget="scrollableDiv"
style={{ overflow: 'visible', width: '100%' }}
>
{data.length === 0 && !loading ? (
<Empty size={200} />
) : (
<Row gutter={[16, 16]} className="rb:mb-2" style={{ margin: 0 }}>
{data.map((item) => {
const modelInfo = modelMenus[item.id];
const hasModelInfo = modelInfo && modelInfo.menu.length > 1;
return (
<Col xs={12} sm={12} md={12} lg={8} xl={8} key={item.id} >
<RbCard
title={item.name}
className='rb:min-h-[198px]'
extra={
<div onClick={(e) => e.stopPropagation()}>
<Dropdown menu={{ items: getOptMenuItems(item) }} >
<EllipsisOutlined className="rb:cursor-pointer" />
</Dropdown>
</div>
}
>
<div className='rb:min-h-[158px]' onClick={() => handleToDetail(item)}>
<div className='rb:min-h-[124px]'>
{item.descriptionItems?.map((description: Record<string, unknown>) => (
<div
key={description.key as string}
className="rb:flex rb:gap-4 rb:justify-start rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]"
>
<div className="rb:whitespace-nowrap rb:w-20">{(description.label as string)}</div>
<div className={clsx('rb:flex-inline rb:text-left rb:py-[1px] rb:rounded rb:font-medium',{
"rb:text-[#155eef] rb:bg-[rgba(21,94,239,0.06)] rb:px-2 rb:border rb:border-[rgba(21,94,239,0.25)] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.private'),
"rb:text-[#369F21] rb:bg-[rgba(54,159,33,0.06)] rb:px-2 rb:border rb:border-[rgba(54,159,33,0.25);] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.share'),
})}>{(description.children as string)}</div>
</div>
))}
{item.description && (
<div className="rb:flex rb:text-[#5B6167] rb:h-10 rb:line-clamp-2 rb:text-sm rb:leading-5 rb:mb-3 rb:gap-4">
<div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div>
<Tooltip title={item.description}>
<div className='rb:flex-1 rb:text-left rb:leading-5 rb:text-gray-800 rb:break-words rb:line-clamp-2'>{item.description || t('knowledgeBase.noDescription')}</div>
</Tooltip>
</div>
)}
</div>
{hasModelInfo && (
<Dropdown menu={{ items: modelInfo.menu }}>
<div
className="rb:flex rb:text-gray-500 rb:px-3 rb:py-2 rb:text-[12px] rb:leading-4 rb:mb-2 rb:bg-[#F0F3F8] rb:rounded"
onClick={(e) => e.stopPropagation()}
>
<span>{t('knowledgeBase.models')}:</span>
<span className="rb:ml-1 rb:truncate rb:max-w-[200px]">
{modelInfo.summary.join('、')}
</span>
</div>
</Dropdown>
)}
</div>
</RbCard>
</Col>
)})}
</Row>
)}
</InfiniteScroll>
<CreateModal
ref={modalRef}
refreshTable={handleRefresh}
/>
</div>
</>
)
}
export default KnowledgeBaseManagement

View File

@@ -0,0 +1,280 @@
import { request } from "@/utils/request";
import type { AxiosProgressEvent } from "axios";
import type {
ShareRequestParams,
SpaceItem,
UploadFileFormData,
FolderFormData,
UploadFileResponse,
Model,
PageRequest,
KnowledgeBase,
KnowledgeBaseFormData,
ListQuery,
PathQuery,
KnowledgeBaseDocumentData,
KnowledgeBaseListResponse,
KnowledgeBaseShareListResponse,
} from "./types";
const apiPrefix = '';
// 从路由中获取空间ID (#号后第一个路径段)
export const getSpaceIdFromRoute = (): string | null => {
if (typeof window === 'undefined') return null;
const hash = window.location.hash;
if (!hash || hash === '#') return null;
// 移除 # 号,然后分割路径
const path = hash.slice(1); // 移除 #
const segments = path.split('/').filter(Boolean); // 分割并过滤空字符串
return segments.length > 0 ? segments[0] : null;
};
export const spaceId = getSpaceIdFromRoute();
//获取知识库类型 (返回字符串数组,每个字符串是 KnowledgeBase 的 type 值)
export const getKnowledgeBaseTypeList = async (): Promise<string[]> => {
const response = await request.get(`${apiPrefix}/knowledges/knowledgetype`);
// 如果直接返回字符串数组,直接返回
if (Array.isArray(response)) {
return response.map(item => {
// 如果是字符串,直接返回
if (typeof item === 'string') {
return item;
}
// 如果是对象且有 type 字段,提取 type 值
if (typeof item === 'object' && item !== null && 'type' in item) {
return String(item.type);
}
// 其他情况转换为字符串
return String(item);
});
}
// 如果不是数组,返回空数组
return [];
};
// 知识库文档解析类型
export const getKnowledgeBaseDocumentParseTypeList = async () => {
const response = await request.get(`${apiPrefix}/knowledges/parsertype`);
return response as any[];
};
//获取模型类型
export const getModelTypeList = async () => {
const response = await request.get(`${apiPrefix}/models/type`);
return response as any[];
};
// 获取模型列表
export const getModelList = async (type: string | string[], pageInfo: PageRequest) => {
const response = await request.get(`${apiPrefix}/models`, { type, ...pageInfo });
return response as any;
};
//获取模型提供者
export const getModelProviderList = async () => {
const response = await request.get(`${apiPrefix}/models/provider`);
return response as any[];
};
// 获取模型信息
export const getModelDetail = async (id: string) => {
const response = await request.get(`${apiPrefix}/models/${id}`);
return response as Model;
};
// 知识库列表
export const getKnowledgeBaseList = async (parent_id?: string, query?: ListQuery) => {
const response = await request.get(`${apiPrefix}/knowledges/knowledges`, query);
return response as KnowledgeBaseListResponse;
};
// 知识库详情
export const getKnowledgeBaseDetail = async (id: string) => {
const response = await request.get(`${apiPrefix}/knowledges/${id}`);
return response as KnowledgeBase;
};
// 创建知识库
export const createKnowledgeBase = async (data: KnowledgeBaseFormData) => {
const payload: KnowledgeBaseFormData = {
...data,
permission_id: data.permission_id ?? 'private',
};
const response = await request.post(`${apiPrefix}/knowledges/knowledge`, payload);
return response as KnowledgeBase;
};
// 更新知识库
export const updateKnowledgeBase = async (id: string, data: KnowledgeBaseFormData) => {
const payload: KnowledgeBaseFormData = {
...data,
};
const response = await request.put(`${apiPrefix}/knowledges/${id}`, payload);
return response as any;
};
// 删除知识库(软删除)
export const deleteKnowledgeBase = async (id: string) => {
const response = await request.delete(`${apiPrefix}/knowledges/${id}`);
return response as any;
}
// 知识库分享 获取分享空间列表
export const getShareSpaceList = async (id: string) => {
const response = await request.get(`${apiPrefix}/knowledgeshares/${id}/knowledgeshares`);
return response as KnowledgeBaseShareListResponse;
}
// 获取文件夹列表
export const getFolderList = async (query: FolderFormData) => {
const id = query.parent_id ?? query.kb_id;
const response = await request.get(`${apiPrefix}/files/${query.kb_id}/${id}/files`);
return response as any;
};
// 创建文件夹
export const createFolder = async (params: FolderFormData) => {
const response = await request.post(`${apiPrefix}/files/folder`, undefined, {
params,
});
return response as FolderFormData;
};
interface UploadFileOptions {
kb_id?: string;
parent_id?: string;
onUploadProgress?: (event: AxiosProgressEvent) => void;
}
// 上传文件
export const uploadFile = async (data: FormData, options?: UploadFileOptions) => {
const { kb_id, parent_id, onUploadProgress } = options || {};
const params: Record<string, string> = {};
if (kb_id) params.kb_id = kb_id;
if (parent_id) params.parent_id = parent_id;
const response = await request.uploadFile(`${apiPrefix}/files/file`, data, {
params,
onUploadProgress,
});
return response as UploadFileResponse;
};
// 下载文件
export const downloadFile = async (fileId: string, fileName?: string) => {
const token = localStorage.getItem('token');
const url = `${apiPrefix}/files/${fileId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('下载失败');
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// 创建临时链接触发下载
const link = document.createElement('a');
link.href = blobUrl;
link.style.display = 'none';
if (fileName) {
link.setAttribute('download', fileName);
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放 blob URL
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('下载文件失败:', error);
throw error;
}
};
// 更新文件信息
export const updateFile = async (id: string, data: UploadFileFormData) => {
const response = await request.put(`${apiPrefix}/files/${id}`, data);
return response as UploadFileResponse;
};
// 删除文件 文件夹 id
export const deleteFile = async (id: string) => {
const response = await request.delete(`${apiPrefix}/files/${id}`);
return response as any;
};
// 获取文档列表
export const getDocumentList = async (query: PathQuery) => {
const response = await request.get(`${apiPrefix}/documents/${query.kb_id}/${query.parent_id}/documents`, query);
return response as KnowledgeBaseDocumentData[];
};
// 文档详情
export const getDocumentDetail = async (id: string) => {
const response = await request.get(`${apiPrefix}/documents/${id}`);
return response as KnowledgeBaseDocumentData;
};
// 创建文档
export const createDocument = async (data: KnowledgeBaseDocumentData) => {
const response = await request.post(`${apiPrefix}/documents/document`, data);
return response as KnowledgeBaseDocumentData;
};
// 更新文档
export const updateDocument = async (id: string, data: KnowledgeBaseDocumentData) => {
const response = await request.put(`${apiPrefix}/documents/${id}`, data);
return response as KnowledgeBaseDocumentData;
};
// 删除文档
export const deleteDocument = async (id: string) => {
const response = await request.delete(`${apiPrefix}/documents/${id}`);
return response;
};
// 文档解析
export const parseDocument = async (id: string) => {
const response = await request.post(`${apiPrefix}/documents/${id}/chunks`);
return response as any;
};
// 文档分块预览
export const previewDocumentChunk = async (kb_id:string,id: string) => { // id document_id
const response = await request.get(`${apiPrefix}/chunks/${kb_id}/${id}/previewchunks`);
return response as any;
};
//文档分块列表
export const getDocumentChunkList = async (query: PathQuery) => {
const response = await request.get(`${apiPrefix}/chunks/${query.kb_id}/${query.document_id}/chunks`, query);
return response as any;
};
// 回归测试
export const reChunks = async (data: any) => {
const response = await request.post(`${apiPrefix}/chunks/retrieval`, data);
return response as any;
};
// 知识库授权 分享空间列表
export const getWorkspaceAuthorizationList = async (kb_id: string) => {
const response = await request.get(`${apiPrefix}/knowledgeshares/${kb_id}/knowledgeshares`);
return response as any;
};
// 知识库分享
export const shareKnowledgeBase = async (data: ShareRequestParams) => {
const response = await request.post(`${apiPrefix}/knowledgeshares/knowledgeshare`, data);
return response as KnowledgeBase;
}
// 空间列表
export const getSpaceList = async () => {
const response = await request.get(`${apiPrefix}/workspaces`,{include_current:false});
// API 返回的 data 直接是数组,需要包装成 { items: [] } 格式以保持一致性
if (Array.isArray(response)) {
return { items: response };
}
return response as { items: SpaceItem[] };
};
// 更新文档块儿
export const updateDocumentChunk = async (kb_id:string, document_id:string, doc_id:string, data: any) => {
const response = await request.put(`${apiPrefix}/chunks/${kb_id}/${document_id}/${doc_id}`, data);
return response as any;
};
// 文档块儿创建
export const createDocumentChunk = async (kb_id:string, document_id:string, data: any) => {
const response = await request.post(`${apiPrefix}/chunks/${kb_id}/${document_id}/chunk`, data);
return response as any;
};
// 获取检索模式类型
export const getRetrievalModeType = async () => {
const response = await request.get(`${apiPrefix}/chunks/retrieve_type`);
return response as any;
};

View File

@@ -0,0 +1,362 @@
// 知识库表单数据类型
export interface KnowledgeBaseFormData {
workspace_id?: string; // 工作空间ID
id?: string; // 知识库ID 新建时为空
name?: string; // 知识库名称
description?: string; // 描述
avatar?: string; // 头像
embedding_id?: string; // 嵌入模型ID
llm_id?: string; // LLM模型ID
image2text_id?: string; // 图片转文本模型ID
reranker_id?: string; // 重排模型ID
chat_id?: string; // 聊天模型ID
permission_id?: string; // 权限ID
parent_id?: string; // 父ID
type?: string; // 知识库类型
status?: number; // 状态
}
export interface KnowledgeBase {
id: string;
name: string;
description?: string;
created_by?: string; // 创建者
created_at?: string;
updated_at?: string;
doc_num?: number; // 文档数量(数据集数理)
chunk_num?: number; // 总数据量
parser_id?: string; // 解析器ID
parser_config?: ParserConfig; // 解析器配置
embedding_id?: string;
llm_id?: string;
image2text_id?: string;
reranker_id?: string;
permission_id?: string;
type: string;
status?: number; // 状态 1 启用 0 禁用
descriptionItems?: Record<string, unknown>[];
}
export interface RecallTestMetadata {
doc_id: string;
file_id: string;
file_name: string;
file_created_at: string | number;
document_id: string;
knowledge_id: string;
sort_id: number;
score: number | null;
status?: number;
}
export interface RecallTestData {
page_content: string;
vector: null | number[];
metadata: RecallTestMetadata;
children: null | RecallTestData[];
}
export interface RecallTestParams {
query?: string; // 查询问题
kb_ids?: string[]; // 知识库ID
similarity_threshold?: number; // 相似度阈值
vector_similarity_weight?: number; //语义相似度权重
top_k?: number;
hybrid?: boolean; // 是否混合检索
hybrid_weight?: string;
}
// 文件夹
export interface FolderFormData {
id?: string; // 文件夹ID 新建时为空
kb_id: string; // 知识库ID
parent_id: string; // 父ID 最顶层=知识库id
folder_name?: string; // 文件夹名称
page?: number;
pagesize?: number;
// description: string; // 描述
createdAt?: string;
updatedAt?: string;
}
export interface FileMeta {
tag: string; // 标签
}
export interface ParserConfig {
layout_recognize?: string; // 布局识别
chunk_token_num?: number; // 分块token数量
delimiter?: string; // 分隔符
auto_keywords?: number; // 自动关键词
auto_questions?: number; // 自动问题
html4excel?: boolean; // 是否为Excel文件
}
// 文件数据
export interface KnowledgeBaseDocumentData { // 知识库文档数据
id?: string; // 文件ID 新建时为空
file_id?: string; // 文件ID
kb_id?: string; // 知识库ID
parent_id?: string; // 文件夹ID
file_name?: string; // 文件名称
file_ext?: string; // 文件扩展名
file_size?: number; // 文件大小
file_meta?: FileMeta; // 文件元数据
parser_id?: string; // 解析器ID
parser_config?: ParserConfig; // 解析器配置
chunk_num?: number; // 分块数量
progress?: number; // 进度 1 完成
progress_msg?: string; // 进度消息
process_begin_at?: string; // 处理开始时间
process_duration?: number; // 处理持续时间
run?: number; // 运行次数
status?: number; // 状态 1 可检索 0 不可检索
created_at?: string; // 创建时间
updated_at?: string; // 更新时间
}
export interface DocumentModalRef {
handleOpen: (file?: KnowledgeBaseDocumentData | null) => void;
}
export interface DocumentModalRefProps {
refreshTable?: () => void;
}
export interface KnowledgeBaseFormRef {
handleOpen: (knowledgeBase?: KnowledgeBase | null) => void;
}
export interface KnowledgeBaseModalRef {
handleOpen: (knowledgeBase?: KnowledgeBase | null) => void;
}
export interface KnowledgeBaseModalProps {
refreshTable?: () => void;
}
// 定义组件暴露的方法接口
export interface CreateModalRef {
handleOpen: (knowledgeBaseListItem?: KnowledgeBaseListItem | null, type?: string) => void;
}
export interface CreateModalRefProps {
refreshTable?: () => void;
}
//
export interface RecallTestDrawerRef {
handleOpen: (knowledgeBaseId?: string) => void;
}
export interface CreateFolderModalRef {
handleOpen: (folder?: FolderFormData | null,type?:string) => void;
}
export interface CreateFolderModalRefProps{
refreshTable?: () => void;
}
//他建图片数据集
export interface CreateImageModalRef{
handleOpen: (kb_id:string,parent_id:string) => void;
}
export interface CreateImageMoealRefProps{
refreshTable?: () => void;
}
// 分享
export interface ShareModalRef {
handleOpen: (kb_id?: string,knowledgeBase?: KnowledgeBase | null) => void;
}
export interface ShareModalRefProps {
handleShare?: (selectedData: { checkedItems: any[], selectedItem: any | null }) => void;
}
// 创建数据集
export interface CreateDatasetModalRef {
handleOpen: (kb_id?: string,parent_id?: string) => void;
}
export interface CreateDatasetModalRefProps {
handleCreateDataset?: (payload: { value: number; title: string; description: string }) => void;
}
// ========== API 相关类型 ==========
// 分页请求信息
export interface PageRequest {
page?: number;
pagesize?: number;
}
// 分页信息
export interface PageInfo {
page_num?: number;
page_size?: number;
total?: number;
has_next?: boolean;
}
// 列表查询参数
export interface ListQuery {
page?: number;
pagesize?: number;
orderby?: string;
desc?: boolean;
keywords?: string;
[key: string]: unknown;
}
// API Key 信息
export interface ModelAPIKey {
model_name: string;
provider: string;
api_key: string;
api_base: string;
config: Record<string, unknown>;
is_active: boolean;
priority: string;
id: string;
model_config_id: string;
usage_count: string;
last_used_at: string | null;
created_at: string;
updated_at: string;
}
// 模型信息
export interface Model {
name: string;
type: string;
description: string | null;
config: Record<string, unknown>;
is_active: boolean;
is_public: boolean;
id: string;
created_at: string;
updated_at: string;
api_keys: ModelAPIKey[];
}
// 创建用户信息
export interface CreatedUser {
id: string;
username: string;
email: string;
is_active: boolean;
is_superuser: boolean;
created_at: string;
}
// 知识库列表项(包含嵌套对象)
export interface KnowledgeBaseListItem extends KnowledgeBase {
workspace_id: string;
parent_id: string;
avatar?: string;
reranker_id?: string;
created_user: CreatedUser;
embedding?: Model;
reranker?: Model;
llm?: Model;
image2text?: Model;
}
// 知识库列表响应
export interface KnowledgeBaseListResponse {
items: KnowledgeBaseListItem[];
page: PageInfo;
}
// 目标空间(分享的目标工作空间)
export interface ShareSpace {
id: string;
name: string;
description?: string;
tenant_id: string;
created_at: string;
}
// 分享用户信息
export interface SharedUser {
id: string;
username: string;
email: string;
is_active: boolean;
is_superuser: boolean;
created_at: string;
}
export interface ShareRequestParams {
source_kb_id?: string;
source_workspace_id?: string;
target_workspace_id?: string;
}
// 知识库分享记录
export interface KnowledgeBaseShare {
id: string;
source_kb_id: string;
source_workspace_id: string;
target_kb_id: string;
target_workspace_id: string;
shared_by: string;
created_at: string;
updated_at: string;
target_kb: KnowledgeBase;
target_workspace: ShareSpace;
shared_user: SharedUser;
}
// 知识库分享列表响应
export interface KnowledgeBaseShareListResponse {
list: KnowledgeBaseShare[];
page: PageInfo;
}
// 文件上传
export interface UploadFileFormData {
kb_id?: string;
parent_id?: string;
file: File;
}
export interface UploadFileResponse extends UploadFileFormData{
id: string;
file_id: string;
file_name: string;
file_size: number;
file_ext: string;
file_meta: FileMeta;
parser_id: string; // 解析器ID
parser_config: ParserConfig; // 解析器配置
chunk_num: number; // 分块数量
progress: number; // 进度 1 完成
progress_msg: string; // 进度消息
process_begin_at: string; // 处理开始时间
process_duration: number; // 处理持续时间
run: number; // 运行次数
status: number; // 状态 1 可检索 0 不可检索
created_at: string;
updated_at: string;
}
export interface FileMeta {
tag: string; // 标签
}
export interface PathQuery extends ListQuery {
kb_id?: string;
parent_id?: string;
workspace_id?: string;
}
//
export interface SpaceItem {
id: string; // 空间ID
name: string; // 空间名称
icon?: string | null; // 空间图标
iconType?: string | null; // 空间图标类型
tenant_id: string; // 租户ID
description?: string | null; // 描述
created_at?: number; // 创建时间(时间戳)
updated_at?: string; // 更新时间
is_active: boolean; // 是否启用
}
// 分享空item
export interface ShareSpaceItem{
}
// 分享 to 空间
export interface ShareSpaceModalRef{
handleOpen: (kb_id?: string,knowledgeBase?: KnowledgeBase | null, spaceIds?:string) => void;
}
export interface ShareSpaceModalRefProps {
handleShare?: () => void;
}

View File

@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Form, App } from 'antd';
import { useUser, type LoginInfo } from '@/store/user';
import type { FormProps } from 'antd';
import { login } from '@/api/user'
import loginBg from '@/assets/images/login/loginBg.png'
import check from '@/assets/images/login/check.png'
import email from '@/assets/images/login/email.svg'
import lock from '@/assets/images/login/lock.svg'
import type { LoginForm } from './types';
const inputClassName = "rb:rounded-[8px]! rb:p-[12px]! rb:h-[44px]!"
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const { clearUserInfo, updateLoginInfo, getUserInfo } = useUser();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<LoginForm>();
const { message } = App.useApp();
useEffect(() => {
clearUserInfo();
}, []);
// 处理登录提交
const handleLogin: FormProps<LoginForm>['onFinish'] = async (values) => {
if (!values.email) {
message.warning(t('login.emailPlaceholder'));
return;
}
if (!values.password) {
message.warning(t('login.passwordPlaceholder'));
return;
}
setLoading(true);
login(values).then((res) => {
const response = res as LoginInfo;
updateLoginInfo(response);
getUserInfo(true)
}).finally(() => {
setLoading(false);
});
};
return (
<div className="rb:min-h-screen rb:flex rb:h-screen">
<div className="rb:relative rb:w-1/2 rb:h-screen rb:overflow-hidden">
<img src={loginBg} alt="loginBg" className="rb:w-full rb:h-full rb:object-cover rb:absolute rb:top-1/2 rb:-translate-y-1/2 rb:left-0" />
<div className="rb:absolute rb:top-[56px] rb:left-[64px]">
<div className="rb:text-[28px] rb:leading-[33px] rb:font-bold rb:font-[AlimamaShuHeiTi,AlimamaShuHeiTi] rb:mb-[16px]">{t('login.title')}</div>
<div className="rb:text-[18px] rb:leading-[25px] rb:font-regular">{t('login.subTitle')}</div>
</div>
<div className="rb:absolute rb:bottom-[81px] rb:left-[64px] rb:grid rb:grid-cols-2 rb:gap-x-[120px] rb:gap-y-[43px]">
{['intelligentMemory', 'instantRecall', 'knowledgeAssociation'].map(key => (
<div key={key} className="rb:flex">
<img src={check} className="rb:w-[16px] rb:h-[16px] rb:mr-[8px] rb:mt-[3px]" />
<div className="rb:text-[16px] rb:leading-[22px]">
<div className="rb:font-medium">{t(`login.${key}`)}</div>
<div className="rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:font-regular! rb:mt-[8px]">{t(`login.${key}Desc`)}</div>
</div>
</div>
))}
</div>
</div>
<div className="rb:bg-[#FFFFFF] rb:flex rb:items-center rb:justify-center rb:flex-[1_1_auto]">
<div className="rb:w-[400px] rb:mx-auto">
<div className="rb:text-center rb:text-[28px] rb:font-semibold rb:leading-[32px] rb:mb-[48px]">{t('login.welcome')}</div>
<Form
form={form}
onFinish={handleLogin}
>
<Form.Item name="email" className="rb:mb-[20px]!">
<Input
prefix={<img src={email} className="rb:w-[20px] rb:h-[20px] rb:mr-[8px]" />}
placeholder={t('login.emailPlaceholder')}
className={inputClassName}
/>
</Form.Item>
<Form.Item name="password">
<Input.Password
prefix={<img src={lock} className="rb:w-[20px] rb:h-[20px] rb:mr-[8px]" />}
placeholder={t('login.passwordPlaceholder')}
className={inputClassName}
/>
</Form.Item>
<Button
type="primary"
block
loading={loading}
htmlType="submit"
className="rb:h-[40px]! rb:rounded-[8px]! rb:mt-[16px]"
>
{t('login.loginIn')}
</Button>
</Form>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,4 @@
export interface LoginForm {
email: string;
password: string;
}

View File

@@ -0,0 +1,161 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, Select, Modal, App } from 'antd';
import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
import copy from 'copy-to-clipboard'
import type { MemberModalData, Member, MemberModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { inviteMember, updateMember } from '@/api/member'
const FormItem = Form.Item;
const { Option } = Select;
type LabelRender = SelectProps['labelRender'];
interface MemberModalProps {
refreshTable: () => void;
}
const MemberModal = forwardRef<MemberModalRef, MemberModalProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp()
const initialForm = {
// role: 'member',
}
const [visible, setVisible] = useState(false);
const [editingUser, setEditingUser] = useState<Member | null>(null);
const [form] = Form.useForm<MemberModalData>();
const [loading, setLoading] = useState(false)
const [modal, contextHolder] = Modal.useModal();
const roleOptions = [
'member',
'manager'
]
const values: MemberModalData = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setEditingUser(null);
form.resetFields();
setLoading(false)
};
const handleOpen = (member?: Member | null) => {
if (member) {
setEditingUser(member);
// 设置表单值
form.setFieldsValue({
email: member.account,
role: member.role
});
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
const response = editingUser?.id
? updateMember({
role: values.role,
id: editingUser?.id
}) : inviteMember(values)
response.then((res) => {
setLoading(false)
refreshTable()
if (editingUser?.id) {
refreshTable()
handleClose()
} else {
const inviteLink = `${window.location.origin}/#/invite-register/${(res as { invite_token: string }).invite_token}`
modal.confirm({
title: t('member.inviteLinkTip'),
content: <a href={inviteLink} target="_blank" rel="noopener noreferrer">{inviteLink}</a>,
okText: t('common.copy'),
okType: 'danger',
onOk: () => {
copy(inviteLink)
handleClose()
message.success(t('common.copySuccess'))
}
})
}
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
const labelRender: LabelRender = (props) => {
const { label, value } = props;
if (label) {
return t(`member.${value}`);
}
return <span>No option match</span>;
};
return (
<RbModal
title={t(editingUser ? 'member.editMember' : 'member.createMember')}
open={visible}
onCancel={handleClose}
okText={editingUser ? t('common.save') : t('member.sendInvitation')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
initialValues={initialForm}
layout="vertical"
>
<FormItem
name="email"
label={t('member.email')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.inputPlaceholder', { title: t('member.email') })} disabled={!!editingUser} />
</FormItem>
<FormItem
name="role"
label={t('member.inviteToMember')}
rules={[{ required: true, message: t('common.select') }]}
>
<Select
placeholder={t('common.select')}
labelRender={labelRender}
>
{roleOptions.map(key => (
<Option key={key} value={key}>
{t(`member.${key}`)}
<div className="rb:text-[#5B6167] rb:text-[12px]">{t(`member.${key}Desc`)} </div>
</Option>
))}
</Select>
</FormItem>
</Form>
{contextHolder}
</RbModal>
);
});
export default MemberModal;

View File

@@ -0,0 +1,116 @@
import React, { useRef } from 'react';
import { App, Button, Space } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ColumnsType } from 'antd/es/table';
import type { AnyObject } from 'antd/es/_util/type';
import { deleteMember, memberListUrl } from '@/api/member';
import MemberModal from './components/MemberModal';
import type { Member, MemberModalRef } from './types'
import Tag from '@/components/Tag';
import Table, { type TableRef } from '@/components/Table'
import { formatDateTime } from '@/utils/format';
const MemberManagement: React.FC = () => {
const { t } = useTranslation();
const { message, modal } = App.useApp();
const memberFormRef = useRef<MemberModalRef>(null);
const tableRef = useRef<TableRef>(null);
// 打开新增用户弹窗
const handleEdit = (member?: Member) => {
if (memberFormRef.current) {
memberFormRef.current.handleOpen(member);
}
}
// 刷新列表数据
const refreshTable = () => {
tableRef.current?.loadData()
}
// 单个删除用户
const handleDelete = async (member: Member) => {
modal.confirm({
title: t('common.confirmDeleteDesc', { name: member.username }),
okText: t('common.delete'),
okType: 'danger',
onOk: () => {
deleteMember(member.id)
.then(() => {
message.success(t('member.deleteSuccess'));
refreshTable();
})
}
})
};
// 表格列配置
const columns: ColumnsType = [
{
title: t('member.username'),
dataIndex: 'username',
key: 'username',
},
{
title: t('member.account'),
dataIndex: 'account',
key: 'account',
},
{
title: t('member.role'),
dataIndex: 'role',
key: 'role',
render: (role: string) => {
return <Tag color={role === 'member' ? 'processing' : 'error'}>{t(`member.${role}`)}</Tag>
},
},
{
title: t('member.lastLoginTime'),
dataIndex: 'last_login_at',
key: 'last_login_at',
render: (last_login_at: string) => formatDateTime(last_login_at, 'YYYY-MM-DD HH:mm:ss'),
},
{
title: t('common.operation'),
key: 'action',
render: (_, record: AnyObject) => (
<Space size="large">
<Button
type="link"
onClick={() => handleEdit(record as Member)}
>
{t('common.edit')}
</Button>
<Button type="link" danger onClick={() => handleDelete(record as Member)}>
{t('common.delete')}
</Button>
</Space>
),
},
];
return (
<>
<div className="rb:flex rb:justify-end rb:mb-[12px]">
<Button type="primary" onClick={() => handleEdit()}>
{t('member.createMember')}
</Button>
</div>
<Table
ref={tableRef}
apiUrl={memberListUrl}
columns={columns}
rowKey="id"
pagination={false}
/>
<MemberModal
ref={memberFormRef}
refreshTable={refreshTable}
/>
</>
);
};
export default MemberManagement;

View File

@@ -0,0 +1,17 @@
// 用户数据类型
export interface Member {
id: string;
username: string;
account: string;
role: string;
last_login_at: string | number;
}
// 用户表单数据类型
export interface MemberModalData {
email: string;
role: string;
}
// 定义组件暴露的方法接口
export interface MemberModalRef {
handleOpen: (user?: Member | null) => void;
}

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

View File

@@ -0,0 +1,53 @@
import { type FC, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import clsx from 'clsx';
import RbCard from '@/components/RbCard/Card'
import down from '@/assets/images/userMemory/down.svg'
interface CardProps {
type?: string;
title: string | ReactNode;
subTitle?: string | ReactNode;
children: ReactNode;
expanded?: boolean;
handleExpand?: (type: string) => void;
className?: string;
bodyClassName?: string;
}
const Card: FC<CardProps> = ({
type,
title,
subTitle,
children,
expanded,
handleExpand,
className,
bodyClassName,
}) => {
const { t } = useTranslation()
return (
<RbCard
title={title}
subTitle={subTitle}
headerType="borderless"
extra={type && handleExpand && (
<div
className="rb:flex rb:items-center rb:text-[14px] rb:text-[#5B6167] rb:cursor-pointer rb:font-regular rb:leading-[20px]"
onClick={() => handleExpand(type)}
>
{expanded ? t('common.foldUp') : t('common.expanded')}
<img src={down} className={clsx("rb:w-[16px] rb:h-[16px] rb:ml-[4px]", {
'rb:rotate-180': !expanded,
})} />
</div>
)}
className={className}
bodyClassName={bodyClassName}
>
{(expanded || !(type && handleExpand)) && children}
</RbCard>
)
}
export default Card

View File

@@ -0,0 +1,658 @@
import { type FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Space, Switch, Select, InputNumber, Slider, Button, App, Skeleton, Form } from 'antd'
import { ExclamationCircleFilled, CheckCircleFilled } from '@ant-design/icons'
import clsx from 'clsx'
import Card from './components/Card'
import RbCard from '@/components/RbCard/Card'
import RbAlert from '@/components/RbAlert'
import Empty from '@/components/Empty'
import type { ConfigForm, ConfigVo, Variable, TestResult } from './types'
import { getMemoryExtractionConfig, updateMemoryExtractionConfig, pilotRunMemoryExtractionConfig } from '@/api/memory'
import Markdown from '@/components/Markdown'
import { getModelList } from '@/api/models';
import type { Model } from '@/views/ModelManagement/types'
const keys = [
// 'example',
'storageLayerModule',
'arrangementLayerModule'
]
const configList: ConfigVo[] = [
{
type: 'storageLayerModule',
data: [
{
title: 'entityDeduplicationDisambiguation',
list: [
{
label: 'enableLlmDedupBlockwise',
variableName: 'enable_llm_dedup_blockwise',
control: 'button', // switch
type: 'tinyint',
},
{
label: 'enableLlmDisambiguation',
variableName: 'enable_llm_disambiguation',
control: 'button',
type: 'tinyint',
},
{
label: 'tNameStrict',
control: 'slider',
variableName: 't_name_strict',
type: 'decimal',
},
{
label: 'tTypeStrict',
control: 'slider',
variableName: 't_type_strict',
type: 'decimal',
},
{
label: 'tOverall',
control: 'slider',
variableName: 't_overall',
type: 'decimal',
},
]
},
// 语义锚点标注
{
title: 'semanticAnchorAnnotationModule',
list: [
// 句子提取颗粒度
{
label: 'statementGranularity',
variableName: 'statement_granularity',
control: 'slider',
type: 'decimal',
max: 3,
min: 1,
step: 1,
meaning: 'statementGranularityDesc',
},
// 是否包含对话上下文
{
label: 'includeDialogueContext',
variableName: 'include_dialogue_context',
control: 'button', // switch
type: 'tinyint',
meaning: 'includeDialogueContextDesc'
},
// 上下文文字上限
{
label: 'maxDialogueContextChars',
variableName: 'max_context',
control: 'inputNumber',
min: 100,
type: 'decimal',
meaning: 'maxDialogueContextCharsDesc',
},
]
},
]
},
{
type: 'arrangementLayerModule',
data: [
{
title: 'queryMode',
list: [
{
label: 'deepRetrieval',
variableName: 'deep_retrieval',
control: 'button',
type: 'tinyint',
meaning: 'deepRetrievalMeaning',
},
]
},
{
title: 'dataPreprocessing',
list: [
{
label: 'chunkerStrategy',
variableName: 'chunker_strategy',
control: 'select',
type: 'enum',
options: [
{ label: 'recursiveChunker', value: 'RecursiveChunker' }, // 递归分块
{ label: 'tokenChunker', value: 'TokenChunker' }, // token 分块
{ label: 'semanticChunker', value: 'SemanticChunker' }, // 语义分块
{ label: 'neuralChunker', value: 'NeuralChunker' }, // 神经网络分块
{ label: 'hybridChunker', value: 'HybridChunker' }, // 混合分块
{ label: 'llmChunker', value: 'LLMChunker' }, // LLM 分块
{ label: 'sentenceChunker', value: 'SentenceChunker' }, // 句子分块
{ label: 'lateChunker', value: 'LateChunker' }, // 延迟分块
],
meaning: 'chunkerStrategyDesc',
},
]
},
// 智能语义剪枝
{
title: 'intelligentSemanticPruning',
list: [
// 智能语义剪枝功能
{
label: 'intelligentSemanticPruningFunction',
variableName: 'pruning_enabled',
control: 'button',
type: 'tinyint',
meaning: 'intelligentSemanticPruningFunctionDesc',
},
// 智能语义剪枝场景
{
label: 'intelligentSemanticPruningScene',
variableName: 'pruning_scene',
control: 'select',
type: 'enum',
options: [
{ label: 'education', value: 'education' },
{ label: 'online_service', value: 'online_service' },
{ label: 'outbound', value: 'outbound' },
],
meaning: 'intelligentSemanticPruningSceneDesc',
},
// 智能语义剪枝阈值
{
label: 'intelligentSemanticPruningThreshold',
control: 'slider',
variableName: 'pruning_threshold',
type: 'decimal',
max: 0.9,
min: 0,
step: 0.1,
meaning: 'intelligentSemanticPruningThresholdDesc',
},
]
},
// 自我反思引擎
{
title: 'selfReflexionEngine',
list: [
// 是否启用反思引擎
{
label: 'enableSelfReflexion',
variableName: 'enable_self_reflexion',
control: 'button',
type: 'tinyint',
},
// 迭代周期
{
label: 'iterationPeriod',
variableName: 'iteration_period',
control: 'select',
type: 'enum',
options: [
{ label: 'oneHour', value: '1' },
{ label: 'threeHours', value: '3' },
{ label: 'sixHours', value: '6' },
{ label: 'twelveHours', value: '12' },
{ label: 'daily', value: '24' },
],
meaning: 'iterationPeriodDesc',
},
// 反思范围
{
label: 'reflexionRange',
variableName: 'reflexion_range',
control: 'select',
type: 'enum',
options: [
{ label: 'retrieval', value: 'retrieval' },
{ label: 'database', value: 'database' },
],
meaning: 'reflexionRangeDesc',
},
// 反思基线
{
label: 'reflectOnTheBaseline',
variableName: 'baseline',
control: 'select',
type: 'enum',
options: [
{ label: 'basedOnTime', value: 'TIME' },
{ label: 'basedOnFacts', value: 'FACT' },
{ label: 'basedOnFactsAndTime', value: 'TIME-FACT' },
],
},
]
},
]
}
]
const resultObj = {
extractTheNumberOfEntities: 'entities.extracted_count',
numberOfEntityDisambiguation: 'disambiguation.block_count',
memoryFragments: 'memory.chunks',
numberOfRelationalTriples: 'triplets.count'
}
const ConfigDesc: FC<{ config: Variable, className?: string }> = ({config, className}) => {
const { t } = useTranslation();
return (
<div className={className}>
<Space size={8} className={clsx("rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] ")}>
{config.variableName && <span className="rb:font-regular">{t('memoryExtractionEngine.variableName')}: {config.variableName}</span>}
{config.control && <span className="rb:font-regular">{t('memoryExtractionEngine.control')}: {t(`memoryExtractionEngine.${config.control}`)}</span>}
{config.type && <span className="rb:font-regular">{t('memoryExtractionEngine.type')}: {config.type}</span>}
</Space>
{config.meaning && <div className={clsx("rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px] ")}>{t('memoryExtractionEngine.Meaning')}: {t(`memoryExtractionEngine.${config.meaning}`)}</div>}
</div>
)
}
const MemoryExtractionEngine: FC = () => {
const { t } = useTranslation();
const { message } = App.useApp();
const { id } = useParams()
const [expandedKeys, setExpandedKeys] = useState<string[]>(keys)
const [form] = Form.useForm<ConfigForm>()
const [modelForm] = Form.useForm()
// const [data, setData] = useState<ConfigForm>()
const modelValues = Form.useWatch([], modelForm)
const values = Form.useWatch<ConfigForm>([], form)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [loading, setLoading] = useState(false)
const [runLoading, setRunLoading] = useState(false)
const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false)
const [modelList, setModelList] = useState<Model[]>([])
useEffect(() => {
if (values?.reflexion_range === 'database') {
form.setFieldValue('iteration_period', 24)
setIterationPeriodDisabled(true)
} else {
setIterationPeriodDisabled(false)
}
}, [values])
const getModels = () => {
const requests = [getModelList({ type: 'llm', pagesize: 100, page: 1 }), getModelList({ type: 'chat', pagesize: 100, page: 1 })]
Promise.all(requests)
.then(responses => {
const [chatRes, modelRes] = responses as { items: Model[] }[]
const chatList = chatRes.items || []
const modelList = modelRes.items || []
setModelList([...chatList, ...modelList])
})
}
const getConfig = () => {
if (!id) {
return
}
getMemoryExtractionConfig(id).then(res => {
const response = res as ConfigForm
const initialValues: ConfigForm = {
...response,
t_name_strict: Number(response.t_name_strict || 0),
t_type_strict: Number(response.t_type_strict || 0),
t_overall: Number(response.t_overall || 0),
}
// setData(initialValues)
form.setFieldsValue(initialValues)
modelForm.setFieldsValue({
llm_id: response.llm_id,
})
})
}
useEffect(() => {
if (id) {
getConfig()
getModels()
const lastResult = localStorage.getItem(`${id}_testResult`)
setTestResult(lastResult ? JSON.parse(lastResult) : null)
}
}, [id])
const handleExpand = (key: string) => {
const newKeys = expandedKeys.includes(key) ? expandedKeys.filter(item => item !== key) : [...expandedKeys, key]
setExpandedKeys(newKeys)
}
const handleSave = () => {
if (!id) {
return
}
console.log('values', values)
setLoading(true)
updateMemoryExtractionConfig({
...values,
...modelValues,
config_id: id,
}).then(() => {
message.success(t('common.saveSuccess'))
})
.finally(() => {
setLoading(false)
})
}
const handleRun = () => {
if (!id) {
return
}
setRunLoading(true)
updateMemoryExtractionConfig({
...values,
...modelValues,
config_id: id,
}).then(() => {
pilotRunMemoryExtractionConfig({
config_id: id,
dialogue_text: t('memoryExtractionEngine.exampleText'),
}).then((res) => {
message.success(t('common.testSuccess'))
const response = res as { extracted_result: TestResult }
setTestResult(response.extracted_result || {})
localStorage.setItem(`${id}_testResult`, JSON.stringify(response.extracted_result || {}))
})
.finally(() => {
setRunLoading(false)
})
})
}
return (
<>
<div className="rb:text-[24px] rb:font-semibold rb:leading-[32px] rb:mb-[8px]">{t('memoryExtractionEngine.title')}</div>
<div className="rb:text-[#5B6167] rb:leading-[20px] rb:mb-[24px]">{t('memoryExtractionEngine.subTitle')}</div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Form form={modelForm}>
<Form.Item
label={t('memoryExtractionEngine.model')}
name="llm_id"
>
<Select
placeholder={t('common.pleaseSelect')}
fieldNames={{
label: 'name',
value: 'id',
}}
options={modelList}
/>
</Form.Item>
</Form>
</Col>
</Row>
<Card
type="example"
title={t('memoryExtractionEngine.example')}
expanded={expandedKeys.includes('example')}
handleExpand={handleExpand}
>
{expandedKeys.includes('example') &&
<div className="rb:text-[14px] rb:text-[#5B6167] rb:font-regular rb:leading-[20px]">
<Markdown content={t('memoryExtractionEngine.exampleText')} />
</div>
}
</Card>
<Row gutter={[16, 16]} className="rb:mt-[16px]">
<Col span={14}>
<Form
form={form}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{configList.map((item, index) => (
<Card
type={item.type}
title={t(`memoryExtractionEngine.${item.type}`)}
key={index}
expanded={expandedKeys.includes(item.type)}
handleExpand={handleExpand}
>
<Space size={20} direction="vertical" style={{width: '100%'}}>
{item.data.map(vo => (
<div
key={vo.title}
className={clsx(
`rb:p-[16px_24px] rb:rounded-[8px]`,
'rb:border-[1px] rb:border-[#DFE4ED]',
{
'rb:shadow-[inset_4px_0px_0px_0px_#155EEF]': index % 2 === 0,
'rb:shadow-[inset_4px_0px_0px_0px_#369F21]': index % 2 !== 0,
}
)}
>
<div className="rb:text-[16px] rb:font-medium rb:leading-[22px]">{t(`memoryExtractionEngine.${vo.title}`)}</div>
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px]">{t(`memoryExtractionEngine.${vo.title}SubTitle`)}</div>
{vo.list.map(config => (
<div key={config.label}>
{config.control === 'button' &&
<div className="rb:flex rb:items-center rb:justify-between rb:mt-[24px]">
<div>
<span className="rb:text-[14px] rb:font-medium rb:leading-[20px]">-{t(`memoryExtractionEngine.${config.label}`)}</span>
<ConfigDesc config={config} className="rb:ml-[8px]" />
</div>
<Form.Item
name={config.variableName}
valuePropName="checked"
className="rb:ml-[8px] rb:mb-[0px]!"
>
<Switch />
</Form.Item>
</div>
}
{config.control === 'select' &&
<>
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px] rb:mt-[24px] rb:mb-[8px]">
-{t(`memoryExtractionEngine.${config.label}`)}
</div>
<div className="rb:pl-[8px]">
<Form.Item
name={config.variableName}
>
<Select
disabled={config.variableName === 'iteration_period' && iterationPeriodDisabled}
options={config.options ? config.options.map(item => ({ ...item, label: t(`memoryExtractionEngine.${item.label}`) })) : []}
/>
</Form.Item>
<ConfigDesc config={config} className="rb:mt-[-16px]!" />
</div>
</>
}
{config.control === 'slider' &&
<>
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px] rb:mt-[24px] rb:mb-[8px]">
-{t(`memoryExtractionEngine.${config.label}`)}
</div>
<div className="rb:pl-[8px]">
<ConfigDesc config={config} className="rb:mb-[10px]" />
<Form.Item
name={config.variableName}
>
<Slider
style={{ margin: '0' }}
min={config.min || 0}
max={config.max || 1}
step={config.step || 0.01}
/>
</Form.Item>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-[20px] rb:mt-[-26px]">
{config.min || 0}
<span>{t('memoryExtractionEngine.CurrentValue')}: {values?.[config.variableName as keyof ConfigForm]}</span>
</div>
</div>
</>
}
{config.control === 'inputNumber' &&
<>
<div className="rb:text-[14px] rb:font-medium rb:leading-[20px] rb:mt-[24px] rb:mb-[8px]">
-{t(`memoryExtractionEngine.${config.label}`)}
</div>
<div className="rb:pl-[8px]">
<Form.Item
name={config.variableName}
>
<InputNumber min={config.min || 0} style={{ width: '100%' }} placeholder={t('common.pleaseEnter')} />
</Form.Item>
<ConfigDesc config={config} className="rb:mt-[-16px]!" />
</div>
</>
}
</div>
))}
</div>
))}
</Space>
</Card>
))}
</Space>
</Form>
</Col>
<Col span={10}>
<Card
title={t('memoryExtractionEngine.exampleMemoryExtractionResults')}
subTitle={t('memoryExtractionEngine.exampleMemoryExtractionResultsSubTitle')}
className="rb:min-h-[calc(100vh-330px)]!"
bodyClassName="rb:min-h-[calc(100vh-388px)]"
>
<div
className="rb:min-h-[calc(100vh-480px)] rb:overflow-y-auto"
>
{testResult && Object.keys(testResult).length > 0
? <>
<RbAlert color="orange" icon={<ExclamationCircleFilled />} className="rb:mb-[14px]">
{t('memoryExtractionEngine.warning')}
</RbAlert>
<Space size={16} direction="vertical" style={{ width: '100%' }}>
{resultObj && Object.keys(resultObj).length > 0 &&
<RbCard>
<div className="rb:grid rb:grid-cols-2 rb:gap-[40px_57px]">
{Object.keys(resultObj).map(key => {
const keys = (resultObj as Record<string, string>)[key].split('.')
return (
<div key={key}>
<div className="rb:text-[24px] rb:leading-[30px] rb:font-extrabold">{testResult?.[keys[0] as keyof TestResult]?.[keys[1]]}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px] rb:font-regular">{t(`memoryExtractionEngine.${key}`)}</div>
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#369F21] rb:leading-[14px] rb:font-regular">
{}
{key === 'extractTheNumberOfEntities'
? t(`memoryExtractionEngine.${key}Desc`, {
num: testResult.dedup.total_merged_count,
exact: testResult.dedup.breakdown.exact,
fuzzy: testResult.dedup.breakdown.fuzzy,
llm: testResult.dedup.breakdown.llm,
})
: key === 'numberOfEntityDisambiguation'
? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.disambiguation.effects?.length, block_count: testResult.disambiguation.block_count })
: key === 'numberOfRelationalTriples'
? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.triplets.count })
:t(`memoryExtractionEngine.${key}Desc`)
}
</div>
</div>
)})}
</div>
</RbCard>
}
{testResult?.dedup?.impact && testResult.dedup.impact?.length > 0 &&
<RbCard
title={t('memoryExtractionEngine.entityDeduplicationImpact')}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]!"
>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-medium rb:leading-[16px]">{t('memoryExtractionEngine.identifyDuplicates')}</div>
{testResult.dedup.impact.map((item, index) => (
<div key={index} className="rb:pl-[8px] rb:mt-[8px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px]">
-{t('memoryExtractionEngine.identifyDuplicatesDesc', { ...item })}
</div>
))}
<RbAlert color="blue" icon={<CheckCircleFilled />} className="rb:mt-[12px]">
{t('memoryExtractionEngine.entityDeduplicationImpactDesc', { count: testResult.dedup.impact.length })}
</RbAlert>
</RbCard>
}
{testResult?.disambiguation && testResult.disambiguation?.effects?.length > 0 &&
<RbCard
title={t('memoryExtractionEngine.theEffectOfEntityDisambiguationLLMDriven')}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]!"
>
{testResult.disambiguation.effects.map((item, index) => (
<div key={index} className={clsx("rb:text-[12px] rb:text-[#5B6167] rb:leading-[16px]", {
'rb:mt-[16px]': index > 0,
})}>
<div className="rb:font-medium rb:mb-[8px]">Disagreement Case {index +1}:</div>
-{item.left.name}({item.left.type}) vs {item.right.name}({item.right.type}) <span className="rb:text-[#369F21]">{item.result}</span>
</div>
))}
<RbAlert color="blue" icon={<CheckCircleFilled />} className="rb:mt-[12px]">
{t('memoryExtractionEngine.entityDeduplicationImpactDesc', { count: testResult.dedup.impact.length })}
</RbAlert>
</RbCard>
}
{testResult?.core_entities && testResult?.core_entities.length > 0 &&
<RbCard
title={t('memoryExtractionEngine.coreEntitiesAfterDedup')}
headerType="borderL"
headerClassName="rb:before:bg-[#369F21]!"
>
<div className="rb:grid rb:grid-cols-2 rb:gap-[24px]">
{testResult.core_entities.map(item => (
<div key={item.type} className="rb:text-[12px]">
<div className="rb:text-[#369F21] rb:font-medium">{item.type}({item.count})</div>
<div>
{item.entities.map((entity, index) => (
<div key={index} className="rb:text-[#5B6167] rb:font-regular rb:leading-[16px]">
-{entity}
</div>
))}
</div>
</div>
))}
</div>
</RbCard>
}
{testResult?.triplet_samples && testResult?.triplet_samples.length > 0 &&
<RbCard
title={t('memoryExtractionEngine.extractRelationalTriples')}
headerType="borderL"
headerClassName="rb:before:bg-[#9C6FFF]!"
>
<Space size={8} direction="vertical" className="rb:w-full">
{testResult.triplet_samples.map((item, index) => (
<div key={index} className="rb:text-[12px]">
-({item.subject}, <span className="rb:text-[#9C6FFF] rb:font-medium">{item.predicate}</span>, {item.object})
</div>
))}
</Space>
<RbAlert color="purple" icon={<CheckCircleFilled />} className="rb:mt-[12px]">
{t('memoryExtractionEngine.extractRelationalTriplesDesc', { count: testResult.triplet_samples.length })}
</RbAlert>
</RbCard>
}
</Space>
</>
: loading
? <Skeleton />
: <Empty className="rb:h-full" />
}
</div>
<div className="rb:grid rb:grid-cols-2 rb:gap-[16px] rb:mt-[20px]">
<Button block loading={loading} onClick={handleSave}>{t('common.save')}</Button>
<Button block type="primary" loading={runLoading} onClick={handleRun}>{t('memoryExtractionEngine.debug')}</Button>
</div>
</Card>
</Col>
</Row>
</>
)
}
export default MemoryExtractionEngine

View File

@@ -0,0 +1,91 @@
export interface ConfigForm {
llm_id: string;
config_id?: number | string;
enable_llm_dedup_blockwise: boolean;
enable_llm_disambiguation: boolean;
t_type_strict: string | number;
t_name_strict: string | number;
t_overall: string | number;
deep_retrieval: boolean;
chunker_strategy: string;
pruning_enabled: boolean;
pruning_scene: string;
pruning_threshold: string | number;
enable_self_reflexion: boolean;
iteration_period: number;
reflexion_range: string;
baseline: string;
}
export interface Variable {
label: string;
variableName: string;
control: string;
meaning?: string;
options?: {
label: string;
value: string | number;
}[];
type: string;
min?: number;
max?: number;
step?: number;
}
export interface ConfigVo {
type: string;
data: {
title: string;
list: Variable[]
}[]
}
export interface TestResult {
generated_at: string;
entities: Record<string, number>;
dedup: {
total_merged_count: number;
breakdown: {
exact: number;
fuzzy: number;
llm: number;
};
impact: {
name: string;
type: string;
appear_count: number;
merge_count: number;
}[];
};
disambiguation: {
block_count: number;
effects: {
left: {
name: string;
type: string;
};
right: {
name: string;
type: string;
};
result: string;
}[];
};
memory: {
chunks: number;
};
triplets: {
count: number;
};
core_entities: {
type: string;
count: number;
entities: string[];
}[];
triplet_samples: {
subject: string;
predicate: string;
object: string;
}[];
}

View File

@@ -0,0 +1,110 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { MemoryFormData, Memory, MemoryFormRef } from '../types';
import RbModal from '@/components/RbModal'
import { createMemoryConfig, updateMemoryConfig } from '@/api/memory'
const FormItem = Form.Item;
interface MemoryFormProps {
refresh: () => void;
}
const MemoryForm = forwardRef<MemoryFormRef, MemoryFormProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [editingMemory, setEditingMemory] = useState<Memory | null>(null);
const [form] = Form.useForm<MemoryFormData>();
const [loading, setLoading] = useState(false);
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setEditingMemory(null);
form.resetFields();
setLoading(false);
};
const handleOpen = (memory?: Memory | null) => {
if (memory) {
setEditingMemory(memory);
// 设置表单值
form.setFieldsValue({
config_name: memory.config_name,
config_desc: memory.config_desc,
});
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
const response = editingMemory?.config_id ? updateMemoryConfig({
config_id: editingMemory.config_id,
...values
}) :createMemoryConfig(values)
response.then(() => {
if (refresh) {
refresh();
}
handleClose()
message.success(editingMemory?.config_id ? t('common.updateSuccess') : t('common.createSuccess'))
}).finally(() => {
setLoading(false)
})
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={editingMemory ? t('memory.editConfiguration') : t('memory.createConfiguration')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="config_name"
label={t('memory.configurationName')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.pleaseEnter')} />
</FormItem>
<FormItem
name="config_desc"
label={t('memory.desc')}
>
<Input.TextArea placeholder={t('common.pleaseEnter')} />
</FormItem>
</Form>
</RbModal>
);
});
export default MemoryForm;

View File

@@ -0,0 +1,135 @@
import React, { useState, useEffect, useRef } from 'react';
import { List, Button, Space, App } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import MemoryForm from './components/MemoryForm';
import type { Memory, MemoryFormRef } from '@/views/MemoryManagement/types'
import RbCard from '@/components/RbCard/Card'
import StatusTag from '@/components/StatusTag'
import { getMemoryConfigList, deleteMemoryConfig } from '@/api/memory'
import BodyWrapper from '@/components/Empty/BodyWrapper'
import { formatDateTime } from '@/utils/format';
import clsx from 'clsx'
const MemoryManagement: React.FC = () => {
const { t } = useTranslation();
const { message, modal } = App.useApp();
const navigate = useNavigate();
const [data, setData] = useState<Memory[]>([]);
const [loading, setLoading] = useState(false);
const memoryFormRef = useRef<MemoryFormRef>(null);
useEffect(() => {
loadMoreData()
}, []);
const loadMoreData = () => {
setLoading(true);
getMemoryConfigList()
.then((res) => {
const response = res as Memory[];
const results = Array.isArray(response) ? response : [];
setData(results);
})
.catch(() => {
console.error('Failed to load data');
})
.finally(() => {
setLoading(false);
});
};
// 打开新增标签弹窗
const handleEdit = (config?: Memory) => {
memoryFormRef.current?.handleOpen(config);
}
const handleDelete = (item: Memory) => {
modal.confirm({
title: t('common.confirmDeleteDesc', { name: item.config_name }),
okText: t('common.delete'),
okType: 'danger',
onOk: () => {
deleteMemoryConfig(item.config_id)
.then(() => {
message.success(t('common.deleteSuccess'));
loadMoreData();
})
}
})
};
const handleClick = (id: number, type: string) => {
switch (type) {
case 'memoryExtractionEngine':
navigate(`/memory-extraction-engine/${id}`)
break
case 'forgottenEngine':
navigate(`/forgetting-engine/${id}`)
break
}
}
return (
<>
<div className="rb:text-right rb:mb-[16px]">
<Button type="primary" onClick={() => handleEdit()}>
{t('memory.createConfiguration')}
</Button>
</div>
<BodyWrapper loading={loading} empty={data.length === 0}>
<List
grid={{ gutter: 16, column: 3 }}
loading={loading}
dataSource={data}
renderItem={(item) => (
<List.Item key={item.config_id}>
<RbCard
title={item.config_name}
>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-[17px] rb:font-regular rb:mt-[-4px]">{item.config_desc}</div>
{['memoryExtractionEngine', 'forgottenEngine'].map((key) => (
<div key={key} className="rb:group rb:cursor-pointer rb:bg-[#F0F3F8] rb:h-[40px] rb:rounded-[6px] rb:flex rb:items-center rb:justify-between rb:p-[0_8px_0_12px] rb:mt-[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-[16px] rb:h-[16px] rb:ml-[-3px] 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>
</span>
</div>
))}
<div className={clsx("rb:mt-[16px] rb:text-[12px] rb:leading-[16px] 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-[20px] rb:h-[20px] 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>
<div
className="rb:w-[20px] rb:h-[20px] 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>
</RbCard>
</List.Item>
)}
/>
</BodyWrapper>
<MemoryForm
ref={memoryFormRef}
refresh={loadMoreData}
/>
</>
);
};
export default MemoryManagement;

View File

@@ -0,0 +1,38 @@
// 内存管理表单数据类型
export interface MemoryFormData {
config_id?: number;
config_name: string;
config_desc?: string;
}
// 内存数据类型
export interface Memory {
config_id: number;
config_name: string;
group_id: string;
user_id: string;
apply_id: string;
enable_llm_dedup_blockwise: boolean;
enable_llm_disambiguation: boolean;
deep_retrieval: boolean;
t_type_strict: string;
t_name_strict: string;
t_overall: string;
chunker_strategy: string;
statement_granularity: string;
include_dialogue_context: boolean;
max_context: string;
lambda_mem: string;
lambda_mem: string;
offset: string;
state: boolean;
created_at: string;
updated_at: string;
config_desc: string;
workspace_id: string;
[key: string]: string | number | boolean;
}
// 定义组件暴露的方法接口
export interface MemoryFormRef {
handleOpen: (memory?: Memory | null) => void;
}

View File

@@ -0,0 +1,172 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ModelFormData, Model, ConfigModalRef, ConfigModalProps } from '../types';
import RbModal from '@/components/RbModal'
import CustomSelect from '@/components/CustomSelect'
import { updateModel, addModel, modelTypeUrl, modelProviderUrl } from '@/api/models'
const ConfigModal = forwardRef<ConfigModalRef, ConfigModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [model, setModel] = useState<Model>({} as Model);
const [isEdit, setIsEdit] = useState(false);
const [form] = Form.useForm<ModelFormData>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch<ModelFormData>([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setModel({} as Model);
form.resetFields();
setLoading(false)
setVisible(false);
};
const handleOpen = (model?: Model) => {
if (model) {
setIsEdit(true);
setModel(model);
// 设置表单值
const apiKeyInfo = model.api_keys[0]
form.setFieldsValue({
provider: apiKeyInfo.provider,
model_name: apiKeyInfo.model_name,
api_key: apiKeyInfo.api_key,
api_base: apiKeyInfo.api_base
});
} else {
setIsEdit(false);
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
const data = {
name: values.name,
type: values.type,
api_keys: {
provider: values.provider,
model_name: values.model_name,
api_key: values.api_key,
api_base: values.api_base
},
}
setLoading(true)
const res = isEdit
? updateModel(model.api_keys[0].id, {
provider: values.provider,
model_name: values.model_name,
api_key: values.api_key,
api_base: values.api_base
} as ModelFormData)
: addModel(data as ModelFormData)
res.then(() => {
if (refresh) {
refresh();
}
handleClose()
message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess'))
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={isEdit ? `${model.name} - ${t('model.modelConfiguration')}` : t('model.createModel')}
open={visible}
onCancel={handleClose}
okText={t(`common.${isEdit ? 'save' : 'create'}`)}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
initialValues={{}}
>
{!isEdit && (
<>
<Form.Item
name="name"
label={t('model.displayName')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('model.displayName') }) }]}
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name="type"
label={t('model.type')}
rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('model.type') }) }]}
>
<CustomSelect
url={modelTypeUrl}
hasAll={false}
format={(items) => items.map((item) => ({ label: item, value: item }))}
/>
</Form.Item>
</>
)}
<Form.Item
name="provider"
label={t('model.provider')}
rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('model.provider') }) }]}
>
<CustomSelect
url={modelProviderUrl}
hasAll={false}
format={(items) => items.map((item) => ({ label: item, value: item }))}
/>
</Form.Item>
{/* TODO:改成模型名称 */}
<Form.Item
name="model_name"
label={t('model.modelName')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('model.modelName') }) }]}
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name="api_key"
label={t('model.apiKey')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('model.apiKey') }) }]}
>
<Input.Password placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name="api_base"
label={t('model.apiEndpoint')}
>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
</Form>
</RbModal>
);
});
export default ConfigModal;

View File

@@ -0,0 +1,100 @@
import { useState, useRef, type FC } from 'react';
import { Row, Col, Button } from 'antd'
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import ConfigModal from './components/ConfigModal'
import type { Model, DescriptionItem, ConfigModalRef } from './types'
import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput'
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
import { getModelListUrl } from '@/api/models'
import { formatDateTime } from '@/utils/format';
const ModelManagement: FC = () => {
const { t } = useTranslation();
const [query, setQuery] = useState({})
const configModalRef = useRef<ConfigModalRef>(null)
const scrollListRef = useRef<PageScrollListRef>(null)
const formatData = (data: Model) => {
return [
{
key: 'type',
label: t(`model.type`),
children: data.type || '-',
},
{
key: 'provider',
label: t(`model.provider`),
children: data.api_keys[0].provider || '-',
},
{
key: 'is_active',
label: t(`model.status`),
children: data.is_active ? t(`common.statusEnabled`) : t(`common.statusDisabled`),
},
{
key: 'created',
label: t(`model.created`),
children: data.created_at ? formatDateTime(data.created_at, 'YYYY-MM-DD HH:mm:ss') : '-',
},
]
}
const handleEdit = (model?: Model) => {
configModalRef?.current?.handleOpen(model)
}
const handleSearch = (value?: string) => {
setQuery({ search: value })
}
return (
<div className="rb:w-full">
<Row className='rb:mb-[16px] rb:w-full'>
<Col span={6}>
<SearchInput
placeholder={t('model.searchPlaceholder')}
onSearch={handleSearch}
style={{width: '100%'}}
/>
</Col>
<Col span={18} className="rb:text-right">
<Button type="primary" onClick={() => handleEdit()}>{t('model.createModel')}</Button>
</Col>
</Row>
<PageScrollList
ref={scrollListRef}
url={getModelListUrl}
query={query}
renderItem={(item: Model) => (
<RbCard
title={item.name}
>
{formatData(item)?.map((description: DescriptionItem) => (
<div
key={description.key}
className="rb:flex rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-[20px] rb:mb-[12px]"
>
<span className="rb:whitespace-nowrap">{(description.label as string)}</span>
<span className={clsx({
"rb:text-[#212332]": description.key !== 'is_active',
"rb:text-[#369F21] rb:font-medium": description.key === 'is_active' && item.is_active,
})}>{(description.children as string)}</span>
</div>
))}
<Button className="rb:mt-[8px]" type="primary" ghost block onClick={() => handleEdit(item)}>{t('model.configureBtn')}</Button>
</RbCard>
)}
/>
<ConfigModal
ref={configModalRef}
refresh={() => scrollListRef?.current?.refresh()}
/>
</div>
)
}
export default ModelManagement

View File

@@ -0,0 +1,70 @@
// 模型表单数据类型
export interface ModelFormData extends ApiKey {
name: string;
type: string;
api_keys: ApiKey;
}
export interface DescriptionItem {
key: string;
label: string;
children: string;
}
// 模型类型定义
export interface Model {
id: string;
name: string;
type: string;
description?: string;
config: Record<string, unknown>;
is_active: boolean;
is_public: boolean;
created_at: string | number;
updated_at: string | number;
api_keys: ApiKey[];
// provider: string;
// temperature: number,
// topP: number,
// status: string;
// vectorDimension: number;
// batchSize: number;
// truncateStrategy: string;
// created: string;
// updatedAt: string;
// descriptionItems?: Record<string, unknown>[];
// basicParameters?: string;
// normalization?: string;
// maxInputLength?: number;
// encodingFormat?: string;
// enablePooling?: boolean;
// poolingStrategy?: string;
// apiKey?: string;
// apiEndpoint?: string;
// timeout?: number;
// autoRetry?: boolean;
// retryCount?: number;
}
interface ApiKey {
model_name?: string;
provider: string;
api_key?: string;
api_base?: string;
config?: Record<string, unknown>;
is_active?: boolean;
priority?: string;
id: string;
model_config_id?: string;
usage_count?: string;
last_used_at?: string | null;
created_at?: string;
updated_at?: string;
}
// 定义组件暴露的方法接口
export interface ConfigModalRef {
handleOpen: (model?: Model) => void;
}
export interface ConfigModalProps {
refresh?: () => void;
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import noPermission from '@/assets/images/empty/noPermission.svg';
import Empty from '@/components/Empty';
const NoPermission = () => {
const { t } = useTranslation();
return (
<Empty
url={noPermission}
title={t('empty.noPermission')}
subTitle={t('empty.noPermissionDesc')}
className="rb:h-[calc(100vh-84px)]"
/>
)
}
export default NoPermission;

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import notFoundImg from '@/assets/images/empty/404.svg';
import Empty from '@/components/Empty';
const NotFound = () => {
const { t } = useTranslation();
return (
<Empty
url={notFoundImg}
title={t('empty.notFound')}
subTitle={t('empty.notFoundDesc')}
className="rb:h-[calc(100vh-84px)]"
/>
)
}
export default NotFound;

View File

@@ -0,0 +1,164 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { SpaceModalData, SpaceModalRef, Space } from '../types'
import RbModal from '@/components/RbModal'
import { createWorkspace } from '@/api/workspaces'
import RadioGroupCard from '@/components/RadioGroupCard'
import { getModelListUrl } from '@/api/models'
import CustomSelect from '@/components/CustomSelect'
const FormItem = Form.Item;
interface SpaceModalProps {
refresh: () => void;
}
const types = [
'rag',
'neo4j',
]
const SpaceModal = forwardRef<SpaceModalRef, SpaceModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<SpaceModalData>();
const [loading, setLoading] = useState(false)
const [editVo, setEditVo] = useState<Space | null>(null)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditVo(null)
};
const handleOpen = (space?: Space) => {
if (space) {
setEditVo(space || null)
form.setFieldsValue({
name: space.name,
icon: space.icon
})
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
createWorkspace(values as SpaceModalData)
.then(() => {
setLoading(false)
refresh()
handleClose()
message.success(t('common.createSuccess'))
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`space.${editVo?.id ? 'editSpace' : 'createSpace'}`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="name"
label={t('space.spaceName')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<Form.Item
label={t('space.llmModel')}
name="llm"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'llm', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.embeddingModel')}
name="embedding"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'embedding', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.rerankModel')}
name="rerank"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'rerank', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<FormItem
name="storage_type"
label={t('space.storageType')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<RadioGroupCard
options={types.map((type) => ({
value: type,
label: t(`space.${type}`),
labelDesc: t(`space.${type}Desc`),
// icon: typeIcons[type]
}))}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default SpaceModal;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { List, Button } from 'antd';
import type { Space, SpaceModalRef } from './types';
import SpaceModal from './components/SpaceModal';
import RbCard from '@/components/RbCard/Card'
import { getWorkspaces, switchWorkspace } from '@/api/workspaces'
import BodyWrapper from '@/components/Empty/BodyWrapper'
import Tag from '@/components/Tag'
const SpaceManagement: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Space[]>([]);
const spaceModalRef = useRef<SpaceModalRef>(null);
const loadMoreData = () => {
setLoading(true);
getWorkspaces()
.then((res) => {
const response = res as Space[];
const results = Array.isArray(response) ? response : [];
setData(results);
})
.catch(() => {
console.error('Failed to load data');
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
loadMoreData();
}, []);
const handleCreate = () => {
spaceModalRef.current?.handleOpen();
}
const handleJump = (id: string) => {
switchWorkspace(id)
.then(() => {
localStorage.removeItem('user')
navigate('/')
})
}
return (
<>
<Button type="primary" className="rb:mb-[16px]" onClick={handleCreate}>
{t('space.createSpace')}
</Button>
<BodyWrapper loading={loading} empty={data.length === 0}>
<List
grid={{ gutter: 16, column: 4 }}
dataSource={data}
renderItem={(item) => (
<List.Item key={item.id}>
<RbCard
avatar={<div className="rb:w-[48px] rb:h-[48px] rb:rounded-[8px] rb:mr-[12px] rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{item.name[0]}
</div>}
title={item.name}
subTitle={<Tag className="rb:mt-[4px] rb:font-regular!" color={item.storage_type === 'rag' ? 'processing' : 'warning'}>{t(`space.${item.storage_type || 'neo4j'}`)}</Tag>}
>
<div className={clsx("rb:absolute rb:top-[-1px] rb:right-[-1px] rb:p-[2px_9px] rb:text-[#FFFFFF] rb:leading-[16px] rb:text-[12px] rb:font-regular rb:rounded-[0px_12px_0px_12px]", {
'rb:bg-[#369F21]': item.is_active,
'rb:bg-[#A8A9AA]': !item.is_active,
})}>{item.is_active ? t('space.associated') : t('space.notAssociated')}</div>
<Button type="primary" ghost block className="rb:mt-[40px]" onClick={() => handleJump(item.id)}>
{t('space.enterSpace')}
</Button>
</RbCard>
</List.Item>
)}
className="rb:h-[calc(100vh-148px)] rb:overflow-y-auto rb:overflow-x-hidden"
/>
</BodyWrapper>
<SpaceModal
ref={spaceModalRef}
refresh={loadMoreData}
/>
</>
);
};
export default SpaceManagement;

View File

@@ -0,0 +1,65 @@
// 应用数据类型
export interface Space {
id: string;
name: string;
description?: string;
tenant_id: string;
created_at: string | number;
is_active: boolean;
icon: string;
storage_type: 'rag' | 'neo4j' | null;
}
// 创建表单数据类型
export interface SpaceModalData {
name: string;
type: string;
icon: string;
llm: string;
embedding: string;
rerank: string;
storage_type: string;
}
// 定义组件暴露的方法接口
export interface SpaceModalRef {
handleOpen: (space?: Space) => void;
}
export interface ModelConfigModalRef {
handleOpen: (space?: Space) => void;
}
export interface ModelConfigModalData {
model: string;
[key: string]: string;
}
export interface AiPromptModalRef {
handleOpen: (space?: Space) => void;
}
export interface VariableModalRef {
handleOpen: (space?: Space) => void;
}
export interface VariableModalProps {
refresh: () => void;
}
export interface VariableEditModalRef {
handleOpen: (values?: Variable) => void;
}
export interface Variable {
index?: number;
type: string;
key: string;
name: string;
maxLength?: number;
defaultValue?: string;
options?: string[];
required: boolean;
hidden?: boolean;
}
export interface ApiExtensionModalData {
name: string;
apiEndpoint: string;
apiKey: string;
}
export interface ApiExtensionModalRef {
handleOpen: () => void;
}

View File

@@ -0,0 +1,106 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { CreateModalData, CreateModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { addUser } from '@/api/user'
const FormItem = Form.Item;
interface CreateModalProps {
refreshTable: () => void;
}
const CreateModal = forwardRef<CreateModalRef, CreateModalProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<CreateModalData>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
form.resetFields();
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
addUser(values)
.then(() => {
setLoading(false)
refreshTable()
handleClose()
message.success(t('common.createSuccess'))
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('user.createUser')}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="email"
label={t('user.usernameOrAccount')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="username"
label={t('user.displayName')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter',)} />
</FormItem>
<FormItem
name="password"
label={t('user.initialPassword')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
</Form>
</RbModal>
);
});
export default CreateModal;

View File

@@ -0,0 +1,116 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App, Button, Row, Col } from 'antd';
import { useTranslation } from 'react-i18next';
import copy from 'copy-to-clipboard'
import { randomString } from '@/utils/common'
import { useUser } from '@/store/user';
import type { ResetPasswordModalRef, User } from '../types'
import RbModal from '@/components/RbModal'
import { changePassword } from '@/api/user'
const ResetPasswordModal = forwardRef<ResetPasswordModalRef, { source?: 'resetPassword' | 'changePassword' }>(({ source = 'resetPassword' }, ref) => {
const { t } = useTranslation();
const { message, modal } = App.useApp();
const { logout } = useUser();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<{ new_password?: string }>();
const [loading, setLoading] = useState(false)
const [editVo, setEditVo] = useState<User>()
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = (user: User) => {
form.resetFields();
setEditVo(user)
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
changePassword({ user_id: editVo?.id as string, new_password: values.new_password as string })
.then((res) => {
handleClose()
const password = typeof res === 'string' ? res : values.new_password as string
if (source === 'changePassword') {
logout()
} else {
modal.confirm({
title: <>
{t('user.resetPasswordSuccess')}
<br />
{password}
</>,
okText: t('common.copy'),
okType: 'danger',
onOk: () => {
copy(password)
message.success(t('common.copySuccess'))
}
})
}
})
.finally(() => {
setLoading(false)
})
})
.catch((err) => {
console.log('err', err)
});
}
// 自动生成长度为12的随机密码包含字母、数字、特殊字符
const handleAutoGenerate = () => {
form.setFieldValue('new_password', randomString());
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('user.resetPassword')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<Row gutter={16}>
<Col span={16}>
<Form.Item
name="new_password"
rules={[
{ min: 6, message: t('user.passwordRule') }
]}
className="rb:mb-0! rb:w-[calc(100%-)]"
>
<Input placeholder={t('user.newPasswordPlaceholder')} />
</Form.Item>
</Col>
<Col span={8}>
<Button onClick={handleAutoGenerate}>{t('user.autoGenerate')}</Button>
</Col>
</Row>
</Form>
</RbModal>
);
});
export default ResetPasswordModal;

View File

@@ -0,0 +1,154 @@
import React, { useRef } from 'react';
import { Button, Space, App } from 'antd';
import { useTranslation } from 'react-i18next';
import CreateModal from './components/CreateModal';
import type { CreateModalRef, User, ResetPasswordModalRef } from './types'
import type { ColumnsType } from 'antd/es/table';
import Table, { type TableRef } from '@/components/Table'
import StatusTag from '@/components/StatusTag'
import { deleteUser, enableUser, getUserListUrl } from '@/api/user'
import ResetPasswordModal from './components/ResetPasswordModal'
import { formatDateTime } from '@/utils/format';
const UserManagement: React.FC = () => {
const { t } = useTranslation();
const { message, modal } = App.useApp();
const userFormRef = useRef<CreateModalRef>(null);
const resetPasswordModalRef = useRef<ResetPasswordModalRef>(null);
const tableRef = useRef<TableRef>(null);
// 打开新增用户弹窗
const handleCreate = () => {
userFormRef.current?.handleOpen();
}
// 重置密码
const handleResetPassword = (user: User) => {
resetPasswordModalRef.current?.handleOpen(user);
};
// 刷新列表数据
const refreshTable = () => {
tableRef.current?.loadData()
}
// 启用/停用
const handleChangeStatus = async (record: User) => {
modal.confirm({
title: t(`user.${record.is_active ? 'disabled' : 'enabled'}Confirm`),
okText: t('common.confirm'),
okType: 'danger',
onOk: () => {
const res = record.is_active ? deleteUser(record.id) : enableUser(record.id);
res.then(() => {
message.success(t(`user.${record.is_active ? 'disabled' : 'enabled'}ConfirmSuccess`));
refreshTable();
})
},
})
};
// 表格列配置
const columns: ColumnsType = [
{
title: t('user.userId'),
dataIndex: 'id',
key: 'id',
fixed: 'left',
},
{
title: <>{t('user.username')}<div className="rb:text-[#5B6167] rb:text-[12px] rb:font-medium">({t(`user.subUsername`)})</div></>,
dataIndex: 'email',
key: 'email',
},
{
title: t('user.displayName'),
dataIndex: 'username',
key: 'username',
},
{
title: t('user.role'),
dataIndex: 'is_superuser',
key: 'is_superuser',
render: (isSuperuser: boolean) => isSuperuser ? t('user.superuser') : t('user.normalUser'),
},
{
title: t('user.status'),
dataIndex: 'is_active',
key: 'is_active',
render: (isActive: boolean) => (
<StatusTag
text={isActive ? t('user.enabled') : t('user.disabled')}
status={isActive ? 'success' : 'error'}
/>
),
},
{
title: t('user.createTime'),
dataIndex: 'created_at',
key: 'created_at',
render: (createdAt: string) => formatDateTime(createdAt, 'YYYY-MM-DD HH:mm:ss'),
},
{
title: t('user.lastLoginTime'),
dataIndex: 'last_login_at',
key: 'last_login_at',
render: (lastLoginAt: string) => lastLoginAt ? formatDateTime(lastLoginAt, 'YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: t('common.operation'),
key: 'action',
fixed: 'right',
render: (_, record) => (
<Space size="large">
{record.is_active &&
<Button
type="link"
onClick={() => handleResetPassword(record as User)}
>
{t('user.resetPassword')}
</Button>
}
<Button
type="link"
onClick={() => handleChangeStatus(record as User)}
>
{t(`common.${record.is_active ? 'disabled' : 'enabled'}`)}
</Button>
</Space>
),
},
];
return (
<div className="rb:h-[calc(100vh-80px)] rb:overflow-hidden">
<div className="rb:flex rb:justify-end rb:mb-[12px]">
<Button type="primary" onClick={handleCreate}>
{t('user.createUser')}
</Button>
</div>
<Table
ref={tableRef}
apiUrl={getUserListUrl}
apiParams={{
include_inactive: true,
}}
columns={columns}
rowKey="id"
isScroll={true}
/>
<CreateModal
ref={userFormRef}
refreshTable={refreshTable}
/>
<ResetPasswordModal
ref={resetPasswordModalRef}
/>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1,35 @@
// 用户数据类型
export interface User {
username: string;
email: string;
id: string;
is_active: boolean;
is_superuser: boolean;
created_at: string | number;
last_login_at: string | number;
current_workspace_id?: string;
current_workspace_name?: string;
role: 'member' | 'manager' | null;
[key: string]: unknown;
}
// 用户表单数据类型
export interface CreateModalData {
email: string;
username: string;
password: string;
}
// 用户表单数据类型
export interface CreateModalData {
username: string;
displayName: string;
initialPassword?: string;
}
// 定义组件暴露的方法接口
export interface CreateModalRef {
handleOpen: () => void;
}
export interface ResetPasswordModalRef {
handleOpen: (user: User) => void;
}

Some files were not shown because too many files have changed in this diff Show More