feat: Add base project structure with API and web components
This commit is contained in:
75
web/src/views/ApiParameters/index.tsx
Normal file
75
web/src/views/ApiParameters/index.tsx
Normal 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;
|
||||
22
web/src/views/ApiParameters/types.ts
Normal file
22
web/src/views/ApiParameters/types.ts
Normal 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[]
|
||||
}
|
||||
446
web/src/views/ApplicationConfig/Agent.tsx
Normal file
446
web/src/views/ApplicationConfig/Agent.tsx
Normal 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;
|
||||
154
web/src/views/ApplicationConfig/Api.tsx
Normal file
154
web/src/views/ApplicationConfig/Api.tsx
Normal 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;
|
||||
205
web/src/views/ApplicationConfig/Cluster.tsx
Normal file
205
web/src/views/ApplicationConfig/Cluster.tsx
Normal 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
|
||||
159
web/src/views/ApplicationConfig/ReleasePage.tsx
Normal file
159
web/src/views/ApplicationConfig/ReleasePage.tsx
Normal 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;
|
||||
81
web/src/views/ApplicationConfig/components/AiPromptModal.tsx
Normal file
81
web/src/views/ApplicationConfig/components/AiPromptModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
27
web/src/views/ApplicationConfig/components/Card.tsx
Normal file
27
web/src/views/ApplicationConfig/components/Card.tsx
Normal 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
|
||||
337
web/src/views/ApplicationConfig/components/Chat.tsx
Normal file
337
web/src/views/ApplicationConfig/components/Chat.tsx
Normal 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;
|
||||
131
web/src/views/ApplicationConfig/components/ConfigHeader.tsx
Normal file
131
web/src/views/ApplicationConfig/components/ConfigHeader.tsx
Normal 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;
|
||||
87
web/src/views/ApplicationConfig/components/CopyModal.tsx
Normal file
87
web/src/views/ApplicationConfig/components/CopyModal.tsx
Normal 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;
|
||||
160
web/src/views/ApplicationConfig/components/Knowledge.tsx
Normal file
160
web/src/views/ApplicationConfig/components/Knowledge.tsx
Normal 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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
147
web/src/views/ApplicationConfig/components/ModelConfigModal.tsx
Normal file
147
web/src/views/ApplicationConfig/components/ModelConfigModal.tsx
Normal 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;
|
||||
100
web/src/views/ApplicationConfig/components/ReleaseModal.tsx
Normal file
100
web/src/views/ApplicationConfig/components/ReleaseModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
116
web/src/views/ApplicationConfig/components/SubAgentModal.tsx
Normal file
116
web/src/views/ApplicationConfig/components/SubAgentModal.tsx
Normal 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;
|
||||
23
web/src/views/ApplicationConfig/components/Tag.tsx
Normal file
23
web/src/views/ApplicationConfig/components/Tag.tsx
Normal 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
|
||||
232
web/src/views/ApplicationConfig/components/VariableEditModal.tsx
Normal file
232
web/src/views/ApplicationConfig/components/VariableEditModal.tsx
Normal 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;
|
||||
131
web/src/views/ApplicationConfig/components/VariableList.tsx
Normal file
131
web/src/views/ApplicationConfig/components/VariableList.tsx
Normal 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
|
||||
29
web/src/views/ApplicationConfig/index.module.css
Normal file
29
web/src/views/ApplicationConfig/index.module.css
Normal 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;
|
||||
}
|
||||
61
web/src/views/ApplicationConfig/index.tsx
Normal file
61
web/src/views/ApplicationConfig/index.tsx
Normal 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;
|
||||
194
web/src/views/ApplicationConfig/types.ts
Normal file
194
web/src/views/ApplicationConfig/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
114
web/src/views/ApplicationManagement/index.tsx
Normal file
114
web/src/views/ApplicationManagement/index.tsx
Normal 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;
|
||||
73
web/src/views/ApplicationManagement/types.ts
Normal file
73
web/src/views/ApplicationManagement/types.ts
Normal 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;
|
||||
}
|
||||
256
web/src/views/Conversation/index.tsx
Normal file
256
web/src/views/Conversation/index.tsx
Normal 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
|
||||
13
web/src/views/Conversation/types.ts
Normal file
13
web/src/views/Conversation/types.ts
Normal 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;
|
||||
}
|
||||
195
web/src/views/ForgettingEngine/components/LineChart.tsx
Normal file
195
web/src/views/ForgettingEngine/components/LineChart.tsx
Normal 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.3,lambda_time=1,offset=0.05; 慢速遗忘:lambda_mem=1,lambda_time=0.3,offset=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.3,lambda_time=1,offset=0.05;
|
||||
// 慢速遗忘:lambda_mem=1,lambda_time=0.3,offset=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
|
||||
150
web/src/views/ForgettingEngine/index.tsx
Normal file
150
web/src/views/ForgettingEngine/index.tsx
Normal 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;
|
||||
48
web/src/views/ForgettingEngine/types.ts
Normal file
48
web/src/views/ForgettingEngine/types.ts
Normal 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;
|
||||
}
|
||||
23
web/src/views/Home/components/Card.tsx
Normal file
23
web/src/views/Home/components/Card.tsx
Normal 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;
|
||||
176
web/src/views/Home/components/LineCard.tsx
Normal file
176
web/src/views/Home/components/LineCard.tsx
Normal 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
|
||||
112
web/src/views/Home/components/PieCard.tsx
Normal file
112
web/src/views/Home/components/PieCard.tsx
Normal 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
|
||||
49
web/src/views/Home/components/QuickOperation.tsx
Normal file
49
web/src/views/Home/components/QuickOperation.tsx
Normal 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
|
||||
85
web/src/views/Home/components/RecentActivity.tsx
Normal file
85
web/src/views/Home/components/RecentActivity.tsx
Normal 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
|
||||
50
web/src/views/Home/components/TagList.tsx
Normal file
50
web/src/views/Home/components/TagList.tsx
Normal 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
|
||||
97
web/src/views/Home/components/TopCardList/index.module.css
Normal file
97
web/src/views/Home/components/TopCardList/index.module.css
Normal 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;
|
||||
}
|
||||
81
web/src/views/Home/components/TopCardList/index.tsx
Normal file
81
web/src/views/Home/components/TopCardList/index.tsx
Normal 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
|
||||
154
web/src/views/Home/index.tsx
Normal file
154
web/src/views/Home/index.tsx
Normal 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
|
||||
32
web/src/views/InviteRegister/index.module.css
Normal file
32
web/src/views/InviteRegister/index.module.css
Normal 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;
|
||||
}
|
||||
243
web/src/views/InviteRegister/index.tsx
Normal file
243
web/src/views/InviteRegister/index.tsx
Normal 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;
|
||||
14
web/src/views/InviteRegister/types.ts
Normal file
14
web/src/views/InviteRegister/types.ts
Normal 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;
|
||||
}
|
||||
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal file
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal file
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal file
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal 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;
|
||||
|
||||
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal file
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal 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;
|
||||
|
||||
150
web/src/views/KnowledgeBase/components/CreateDatasetModal.tsx
Normal file
150
web/src/views/KnowledgeBase/components/CreateDatasetModal.tsx
Normal 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;
|
||||
114
web/src/views/KnowledgeBase/components/CreateFolderModal.tsx
Normal file
114
web/src/views/KnowledgeBase/components/CreateFolderModal.tsx
Normal 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;
|
||||
151
web/src/views/KnowledgeBase/components/CreateImageDataset.tsx
Normal file
151
web/src/views/KnowledgeBase/components/CreateImageDataset.tsx
Normal 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;
|
||||
260
web/src/views/KnowledgeBase/components/CreateModal.tsx
Normal file
260
web/src/views/KnowledgeBase/components/CreateModal.tsx
Normal 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;
|
||||
91
web/src/views/KnowledgeBase/components/DelimiterSelector.tsx
Normal file
91
web/src/views/KnowledgeBase/components/DelimiterSelector.tsx
Normal 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;
|
||||
365
web/src/views/KnowledgeBase/components/FolderTree.tsx
Normal file
365
web/src/views/KnowledgeBase/components/FolderTree.tsx
Normal 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;
|
||||
44
web/src/views/KnowledgeBase/components/InfoPanel.tsx
Normal file
44
web/src/views/KnowledgeBase/components/InfoPanel.tsx
Normal 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;
|
||||
156
web/src/views/KnowledgeBase/components/InsertModal.tsx
Normal file
156
web/src/views/KnowledgeBase/components/InsertModal.tsx
Normal 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;
|
||||
199
web/src/views/KnowledgeBase/components/RecallTest.tsx
Normal file
199
web/src/views/KnowledgeBase/components/RecallTest.tsx
Normal 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;
|
||||
60
web/src/views/KnowledgeBase/components/RecallTestDrawer.tsx
Normal file
60
web/src/views/KnowledgeBase/components/RecallTestDrawer.tsx
Normal 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;
|
||||
187
web/src/views/KnowledgeBase/components/RecallTestResult.tsx
Normal file
187
web/src/views/KnowledgeBase/components/RecallTestResult.tsx
Normal 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;
|
||||
137
web/src/views/KnowledgeBase/components/ShareModal.tsx
Normal file
137
web/src/views/KnowledgeBase/components/ShareModal.tsx
Normal 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;
|
||||
127
web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx
Normal file
127
web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx
Normal 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;
|
||||
16
web/src/views/KnowledgeBase/components/noData.tsx
Normal file
16
web/src/views/KnowledgeBase/components/noData.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
web/src/views/KnowledgeBase/constants/delimiter.ts
Normal file
71
web/src/views/KnowledgeBase/constants/delimiter.ts
Normal 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);
|
||||
};
|
||||
72
web/src/views/KnowledgeBase/datasets.tsx
Normal file
72
web/src/views/KnowledgeBase/datasets.tsx
Normal 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;
|
||||
|
||||
4
web/src/views/KnowledgeBase/index.module.css
Normal file
4
web/src/views/KnowledgeBase/index.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.textBg:hover,
|
||||
.textBg.active{
|
||||
background-color: rgba(21, 94, 239, 0.08);
|
||||
}
|
||||
464
web/src/views/KnowledgeBase/index.tsx
Normal file
464
web/src/views/KnowledgeBase/index.tsx
Normal 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
|
||||
|
||||
280
web/src/views/KnowledgeBase/service.ts
Normal file
280
web/src/views/KnowledgeBase/service.ts
Normal 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;
|
||||
};
|
||||
362
web/src/views/KnowledgeBase/types.ts
Normal file
362
web/src/views/KnowledgeBase/types.ts
Normal 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;
|
||||
}
|
||||
106
web/src/views/Login/index.tsx
Normal file
106
web/src/views/Login/index.tsx
Normal 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;
|
||||
4
web/src/views/Login/types.ts
Normal file
4
web/src/views/Login/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface LoginForm {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
161
web/src/views/MemberManagement/components/MemberModal.tsx
Normal file
161
web/src/views/MemberManagement/components/MemberModal.tsx
Normal 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;
|
||||
116
web/src/views/MemberManagement/index.tsx
Normal file
116
web/src/views/MemberManagement/index.tsx
Normal 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;
|
||||
17
web/src/views/MemberManagement/types.ts
Normal file
17
web/src/views/MemberManagement/types.ts
Normal 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;
|
||||
}
|
||||
31
web/src/views/MemoryConversation/components/Card.tsx
Normal file
31
web/src/views/MemoryConversation/components/Card.tsx
Normal 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
|
||||
82
web/src/views/MemoryConversation/components/Chat.tsx
Normal file
82
web/src/views/MemoryConversation/components/Chat.tsx
Normal 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
|
||||
143
web/src/views/MemoryConversation/components/ChatInput.tsx
Normal file
143
web/src/views/MemoryConversation/components/ChatInput.tsx
Normal 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
|
||||
241
web/src/views/MemoryConversation/index.tsx
Normal file
241
web/src/views/MemoryConversation/index.tsx
Normal 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
|
||||
0
web/src/views/MemoryConversation/types.ts
Normal file
0
web/src/views/MemoryConversation/types.ts
Normal file
53
web/src/views/MemoryExtractionEngine/components/Card.tsx
Normal file
53
web/src/views/MemoryExtractionEngine/components/Card.tsx
Normal 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
|
||||
658
web/src/views/MemoryExtractionEngine/index.tsx
Normal file
658
web/src/views/MemoryExtractionEngine/index.tsx
Normal 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
|
||||
91
web/src/views/MemoryExtractionEngine/types.ts
Normal file
91
web/src/views/MemoryExtractionEngine/types.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
110
web/src/views/MemoryManagement/components/MemoryForm.tsx
Normal file
110
web/src/views/MemoryManagement/components/MemoryForm.tsx
Normal 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;
|
||||
135
web/src/views/MemoryManagement/index.tsx
Normal file
135
web/src/views/MemoryManagement/index.tsx
Normal 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;
|
||||
38
web/src/views/MemoryManagement/types.ts
Normal file
38
web/src/views/MemoryManagement/types.ts
Normal 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;
|
||||
}
|
||||
172
web/src/views/ModelManagement/components/ConfigModal.tsx
Normal file
172
web/src/views/ModelManagement/components/ConfigModal.tsx
Normal 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;
|
||||
100
web/src/views/ModelManagement/index.tsx
Normal file
100
web/src/views/ModelManagement/index.tsx
Normal 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
|
||||
70
web/src/views/ModelManagement/types.ts
Normal file
70
web/src/views/ModelManagement/types.ts
Normal 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;
|
||||
}
|
||||
17
web/src/views/NoPermission/index.tsx
Normal file
17
web/src/views/NoPermission/index.tsx
Normal 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;
|
||||
17
web/src/views/NotFound/index.tsx
Normal file
17
web/src/views/NotFound/index.tsx
Normal 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;
|
||||
164
web/src/views/SpaceManagement/components/SpaceModal.tsx
Normal file
164
web/src/views/SpaceManagement/components/SpaceModal.tsx
Normal 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;
|
||||
92
web/src/views/SpaceManagement/index.tsx
Normal file
92
web/src/views/SpaceManagement/index.tsx
Normal 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;
|
||||
65
web/src/views/SpaceManagement/types.ts
Normal file
65
web/src/views/SpaceManagement/types.ts
Normal 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;
|
||||
}
|
||||
106
web/src/views/UserManagement/components/CreateModal.tsx
Normal file
106
web/src/views/UserManagement/components/CreateModal.tsx
Normal 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;
|
||||
116
web/src/views/UserManagement/components/ResetPasswordModal.tsx
Normal file
116
web/src/views/UserManagement/components/ResetPasswordModal.tsx
Normal 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;
|
||||
154
web/src/views/UserManagement/index.tsx
Normal file
154
web/src/views/UserManagement/index.tsx
Normal 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;
|
||||
35
web/src/views/UserManagement/types.ts
Normal file
35
web/src/views/UserManagement/types.ts
Normal 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
Reference in New Issue
Block a user