Merge branch 'develop' into feature/ui_upgrade_zy
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:28:59
|
||||
* @Last Modified time: 2026-03-20 11:36:49
|
||||
*/
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -22,7 +22,8 @@ import type {
|
||||
MemoryConfig,
|
||||
AiPromptModalRef,
|
||||
Source,
|
||||
ChatVariableConfigModalRef
|
||||
ChatVariableConfigModalRef,
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
@@ -41,12 +42,13 @@ import ChatVariableConfigModal from './components/ChatVariableConfigModal';
|
||||
import type { Skill } from '@/views/Skills/types'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import DescWrapper from '@/components/FormItem/DescWrapper'
|
||||
import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
/**
|
||||
* Agent configuration component
|
||||
* Manages single agent configuration including prompts, knowledge, memory, variables, and tools
|
||||
*/
|
||||
const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp()
|
||||
@@ -117,6 +119,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
...response,
|
||||
tools: allTools
|
||||
})
|
||||
onFeaturesLoad?.(response.features)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -272,7 +275,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
}, [modelList, values?.default_model_config_id])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
features: values?.features
|
||||
}))
|
||||
|
||||
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
|
||||
@@ -326,6 +330,10 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
useEffect(() => {
|
||||
setChatVariables(values?.variables || [])
|
||||
}, [values?.variables])
|
||||
|
||||
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
form.setFieldValue('features', value)
|
||||
}
|
||||
console.log('values', values)
|
||||
return (
|
||||
<>
|
||||
@@ -339,13 +347,16 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
{defaultModel?.name ? <div className="rb:size-4 rb:bg-[url('@/assets/images/application/model.svg')]"></div> : null}
|
||||
{defaultModel?.name || t('application.chooseModel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Space size={12}>
|
||||
<FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
|
||||
<Form.Item name="model_parameters" hidden noStyle></Form.Item>
|
||||
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Card
|
||||
title={t('application.promptConfiguration')}
|
||||
extra={
|
||||
@@ -364,25 +375,25 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
<span className="rb:font-regular rb:text-[#5B6167]"> ({t('application.configurationDesc')})</span>
|
||||
</div>
|
||||
|
||||
<Form.Item name="system_prompt" className="rb:mb-0!">
|
||||
<Input.TextArea
|
||||
placeholder={t('application.promptPlaceholder')}
|
||||
styles={{
|
||||
textarea: {
|
||||
minHeight: '200px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Form.Item name="system_prompt" className="rb:mb-0!">
|
||||
<Input.TextArea
|
||||
placeholder={t('application.promptPlaceholder')}
|
||||
styles={{
|
||||
textarea: {
|
||||
minHeight: '200px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="knowledge_retrieval" noStyle>
|
||||
<Knowledge />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="knowledge_retrieval" noStyle>
|
||||
<Knowledge />
|
||||
</Form.Item>
|
||||
|
||||
{/* Memory Configuration */}
|
||||
{/* Memory Configuration */}
|
||||
<Card title={t('application.memoryConfiguration')}>
|
||||
<Flex gap={16} vertical className="rb:bg-[#FAFAFA] rb:rounded-xl rb:p-3!">
|
||||
<SwitchFormItem
|
||||
@@ -403,6 +414,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
hasAll={false}
|
||||
valueKey='config_id'
|
||||
labelKey="config_name"
|
||||
disabled={!values?.memory?.enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
@@ -428,9 +440,6 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
title={t('application.debuggingAndPreview')}
|
||||
extra={
|
||||
<Space size={10}>
|
||||
<Button type="primary" ghost onClick={handleOpenVariableConfig}>
|
||||
{t('application.variableConfig')}
|
||||
</Button>
|
||||
<Button type="primary" ghost onClick={handleAddModel}>
|
||||
+ {t('application.addModel')}
|
||||
</Button>
|
||||
@@ -441,14 +450,15 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
headerClassName="rb:h-[56px]! rb:leading-[22px]!"
|
||||
titleClassName="rb:font-[MiSans-Bold] rb:font-bold"
|
||||
bodyClassName="rb:p-4! rb:pt-0!"
|
||||
className="rb:h-full"
|
||||
className="rb:h-full!"
|
||||
>
|
||||
<Chat
|
||||
data={data as Config}
|
||||
data={values as Config}
|
||||
chatList={chatList}
|
||||
updateChatList={setChatList}
|
||||
handleSave={handleSave}
|
||||
chatVariables={chatVariables}
|
||||
handleEditVariables={handleOpenVariableConfig}
|
||||
/>
|
||||
</RbCard>
|
||||
</Col>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:11:54
|
||||
* @Last Modified time: 2026-03-19 21:03:01
|
||||
*/
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -18,7 +18,8 @@ import type {
|
||||
ChatData,
|
||||
SubAgentItem,
|
||||
ClusterRef,
|
||||
ModelConfigModalRef
|
||||
ModelConfigModalRef,
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import Chat from './components/Chat'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
@@ -28,13 +29,14 @@ import RadioGroupCard from '@/components/RadioGroupCard'
|
||||
import ModelSelect from '@/components/ModelSelect'
|
||||
import ModelConfigModal from './components/ModelConfigModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
// import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
const MAX_LENGTH = 5;
|
||||
/**
|
||||
* Multi-agent cluster configuration component
|
||||
* Manages multi-agent orchestration, sub-agents, and collaboration modes
|
||||
*/
|
||||
const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm()
|
||||
@@ -129,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
} else {
|
||||
setSubAgents(sub_agents)
|
||||
}
|
||||
onFeaturesLoad?.(response.features)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
@@ -167,7 +170,8 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
setSubAgents(prev => prev.filter(item => item.agent_id !== agent.agent_id))
|
||||
}
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
features: data?.features
|
||||
}))
|
||||
|
||||
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
|
||||
@@ -186,6 +190,9 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
model_parameters: values
|
||||
})
|
||||
}
|
||||
// const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
// form.setFieldValue('features', value)
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -195,10 +202,12 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
<Form form={form} layout="vertical">
|
||||
<Flex gap={16} vertical>
|
||||
<Flex align="center" justify="end" className="rb:p-3! rb:bg-white rb:rounded-xl">
|
||||
{/* <FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} /> */}
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Card title={t('application.collaboration')}>
|
||||
<Form.Item
|
||||
name="orchestration_mode"
|
||||
@@ -216,73 +225,73 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<>
|
||||
{t('application.subAgentsManagement')}
|
||||
<span className="rb:font-medium rb:font-[PingFangSC,PingFang_SC]! rb:text-[14px]!"> ({subAgents.length}/{MAX_LENGTH})</span>
|
||||
</>}
|
||||
extra={<Button className="rb:py-0! rb:px-2! rb:h-6!" disabled={subAgents.length >= MAX_LENGTH} onClick={() => handleSubAgentModal()}>+ {t('application.addSubAgent')}</Button>}
|
||||
>
|
||||
{subAgents.length === 0
|
||||
? <div className="rb-border rb:rounded-xl rb:pt-4 rb:pb-6"><Empty size={88} /></div>
|
||||
: <Flex vertical gap={12}>
|
||||
{subAgents.map((agent, index) => (
|
||||
<Flex key={index} align="center" justify="space-between"
|
||||
className="rb:w-full! rb-border rb:rounded-xl rb:py-2.5! rb:pl-4! rb:pr-3!"
|
||||
>
|
||||
<Flex justify="center" vertical className="rb:max-w-[calc(100%-60px)]">
|
||||
<div>
|
||||
<span className="rb:text-[#212332] rb:leading-5">{agent.name}</span>
|
||||
<Tag color={agent.is_active ? 'success' : 'warning'} className="rb:ml-2">
|
||||
{agent.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
{agent.role && <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167] rb:text-[12px] rb:mt-1">{agent.role || '-'}</div>}
|
||||
{agent.capabilities && <Flex wrap gap={8} className="rb:mt-2.5!">
|
||||
{agent.capabilities.map((tag, tagIndex) => <Tag key={tagIndex} color="dark" className="rb:py-0!">{tag}</Tag>)}
|
||||
</Flex>}
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<div
|
||||
className="rb:size-6 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:size-6 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>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{values?.orchestration_mode !== 'collaboration' && <Card title={t('application.masterConfig')}>
|
||||
<Form.Item
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.model')}</span>}
|
||||
required={true}
|
||||
className="rb:mb-4!"
|
||||
<Card
|
||||
title={<>
|
||||
{t('application.subAgentsManagement')}
|
||||
<span className="rb:font-medium rb:font-[PingFangSC,PingFang_SC]! rb:text-[14px]!"> ({subAgents.length}/{MAX_LENGTH})</span>
|
||||
</>}
|
||||
extra={<Button className="rb:py-0! rb:px-2! rb:h-6!" disabled={subAgents.length >= MAX_LENGTH} onClick={() => handleSubAgentModal()}>+ {t('application.addSubAgent')}</Button>}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
<Form.Item name="default_model_config_id" noStyle>
|
||||
<ModelSelect
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model_parameters" noStyle>
|
||||
<Button
|
||||
className="rb:w-33"
|
||||
icon={<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/application/set.svg')]"></div>}
|
||||
onClick={handleEditModelConfig}
|
||||
>{t('application.modelConfig')}</Button>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{subAgents.length === 0
|
||||
? <div className="rb-border rb:rounded-xl rb:pt-4 rb:pb-6"><Empty size={88} /></div>
|
||||
: <Flex vertical gap={12}>
|
||||
{subAgents.map((agent, index) => (
|
||||
<Flex key={index} align="center" justify="space-between"
|
||||
className="rb:w-full! rb-border rb:rounded-xl rb:py-2.5! rb:pl-4! rb:pr-3!"
|
||||
>
|
||||
<Flex justify="center" vertical className="rb:max-w-[calc(100%-60px)]">
|
||||
<div>
|
||||
<span className="rb:text-[#212332] rb:leading-5">{agent.name}</span>
|
||||
<Tag color={agent.is_active ? 'success' : 'warning'} className="rb:ml-2">
|
||||
{agent.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
{agent.role && <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167] rb:text-[12px] rb:mt-1">{agent.role || '-'}</div>}
|
||||
{agent.capabilities && <Flex wrap gap={8} className="rb:mt-2.5!">
|
||||
{agent.capabilities.map((tag, tagIndex) => <Tag key={tagIndex} color="dark" className="rb:py-0!">{tag}</Tag>)}
|
||||
</Flex>}
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<div
|
||||
className="rb:size-6 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:size-6 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>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{values?.orchestration_mode !== 'collaboration' && <Card title={t('application.masterConfig')}>
|
||||
<Form.Item
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.model')}</span>}
|
||||
required={true}
|
||||
className="rb:mb-4!"
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
<Form.Item name="default_model_config_id" noStyle>
|
||||
<ModelSelect
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model_parameters" noStyle>
|
||||
<Button
|
||||
className="rb:w-33"
|
||||
icon={<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/application/set.svg')]"></div>}
|
||||
onClick={handleEditModelConfig}
|
||||
>{t('application.modelConfig')}</Button>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['execution_config',"sub_agent_execution_mode"]}
|
||||
name={['execution_config', "sub_agent_execution_mode"]}
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.orchestrationMode')}</span>}
|
||||
className="rb:mb-4!"
|
||||
>
|
||||
@@ -291,6 +300,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -303,6 +313,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 19:02:43
|
||||
* @Last Modified time: 2026-03-19 21:10:38
|
||||
*/
|
||||
import { type FC, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,10 +11,11 @@ import { Space, Input, Form, App, Flex } from 'antd';
|
||||
|
||||
import Tag, { type TagProps } from './components/Tag'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getReleaseList, rollbackRelease } from '@/api/application'
|
||||
import { getReleaseList, rollbackRelease, appExport } from '@/api/application'
|
||||
import ReleaseModal from './components/ReleaseModal'
|
||||
import ReleaseShareModal from './components/ReleaseShareModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef } from './types'
|
||||
import AppSharingModal from './components/AppSharingModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef, AppSharingModalRef } from './types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
@@ -41,6 +42,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
const { message } = App.useApp()
|
||||
const releaseModalRef = useRef<ReleaseModalRef>(null)
|
||||
const releaseShareModalRef = useRef<ReleaseShareModalRef>(null)
|
||||
const appSharingModalRef = useRef<AppSharingModalRef>(null)
|
||||
const [selectedVersion, setSelectedVersion] = useState<Release | null>(null);
|
||||
const [releaseList, setReleaseList] = useState<Release[]>([])
|
||||
|
||||
@@ -69,6 +71,10 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
message.success(t('common.operateSuccess'))
|
||||
})
|
||||
}
|
||||
const handleExport = () => {
|
||||
if (!selectedVersion) return
|
||||
appExport(data.id, data.name, { release_id: selectedVersion.id})
|
||||
}
|
||||
return (
|
||||
<Flex gap={12}>
|
||||
<div className={`rb:overflow-y-auto rb:w-101 rb:flex-[0_0_auto] ${heightClass}`}>
|
||||
@@ -125,9 +131,10 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
|
||||
<Space size={10}>
|
||||
{selectedVersion && <>
|
||||
{/* <RbButton>{t('application.exportDSLFile')}</RbButton> */}
|
||||
{data?.type !== 'multi_agent' && <RbButton onClick={handleExport}>{t('common.export')}</RbButton>}
|
||||
{data.current_release_id !== selectedVersion.id && <RbButton onClick={handleRollback}>{t('application.willRollToThisVersion')}</RbButton>}
|
||||
<RbButton onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton>
|
||||
<RbButton type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton>
|
||||
{data?.type !== 'multi_agent' && <RbButton type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</RbButton>}
|
||||
</>}
|
||||
<RbButton type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</RbButton>
|
||||
</Space>
|
||||
@@ -184,6 +191,11 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
ref={releaseShareModalRef}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
<AppSharingModal
|
||||
ref={appSharingModalRef}
|
||||
appId={data.id}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
485
web/src/views/ApplicationConfig/TestChat/index.tsx
Normal file
485
web/src/views/ApplicationConfig/TestChat/index.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 20:54:35
|
||||
*/
|
||||
import { type FC, useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import { draftRun } from '@/api/application'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import Chat from '@/components/Chat'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import Runtime from '@/views/Workflow/components/Chat/Runtime'
|
||||
import { nodeLibrary } from '@/views/Workflow/constant'
|
||||
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import type { TestChatProps } from './type'
|
||||
import type { SSEMessage } from '@/utils/stream'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
|
||||
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
|
||||
return {
|
||||
message,
|
||||
conversation_id,
|
||||
stream: true,
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
variables: Object.keys(variables).length > 0 ? variables : undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeData {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
node_type?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed';
|
||||
audio_url?: string;
|
||||
}
|
||||
|
||||
const TestChat: FC<TestChatProps> = ({
|
||||
application,
|
||||
config
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
getVariables()
|
||||
}, [application, config])
|
||||
|
||||
const getVariables = () => {
|
||||
if (!application || !config) return
|
||||
|
||||
setFeatures(config?.features || {} as FeaturesConfigForm)
|
||||
|
||||
let initVariables: Variable[] = []
|
||||
|
||||
switch (application.type) {
|
||||
case 'workflow':
|
||||
const { nodes } = config as WorkflowConfig;
|
||||
const startNodes = nodes.filter(vo => vo.type === 'start')
|
||||
if (startNodes.length) {
|
||||
const curVariables = startNodes[0].config.variables as Variable[]
|
||||
curVariables.forEach((vo) => {
|
||||
if (typeof vo.default !== 'undefined') {
|
||||
vo.value = vo.default
|
||||
}
|
||||
const lastVo = curVariables.find(item => item.name === vo.name)
|
||||
if (lastVo?.value) {
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
initVariables = curVariables
|
||||
}
|
||||
break
|
||||
case 'agent':
|
||||
initVariables = config.variables as Variable[]
|
||||
break
|
||||
}
|
||||
|
||||
toolbarRef.current?.setVariables([...initVariables])
|
||||
}
|
||||
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const { type } = application || {}
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
subContent: type === 'workflow' ? [] : undefined,
|
||||
}])
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string, audio_url?: string) => {
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content += content;
|
||||
lastMsg.meta_data = {audio_url}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = null
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const buildVariableParams = (variables: Variable[]) => {
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables?.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
return { isCanSend, params }
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
setStreamLoading(true)
|
||||
setLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleStreamMessage
|
||||
)
|
||||
.catch(() => {
|
||||
updateErrorAssistantMessage(0)
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; };
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'message':
|
||||
updateAssistantMessage(content)
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length)
|
||||
setStreamLoading(false)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkflowSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
setMessage(undefined)
|
||||
setStreamLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleWorkflowStreamMessage
|
||||
)
|
||||
.catch((error) => {
|
||||
const errorInfo = JSON.parse(error.message)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { content, conversation_id } = item.data as NodeData;
|
||||
switch (item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Track node execution start
|
||||
case 'node_start':
|
||||
addWorkflowNodeStartMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with execution results or errors
|
||||
case 'node_end':
|
||||
case 'node_error':
|
||||
updateWorkflowNodeEndMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with subContent
|
||||
case 'cycle_item':
|
||||
updateWorkflowCycleMessage(item.data as NodeData)
|
||||
break
|
||||
// Mark workflow as complete
|
||||
case 'workflow_end':
|
||||
updateWorkflowEndMessage(item.data as NodeData)
|
||||
setStreamLoading(false)
|
||||
setLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addWorkflowNodeStartMessage = (data: NodeData) => {
|
||||
const { node_id } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === node_id)
|
||||
if (filterIndex > -1) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
}
|
||||
} else {
|
||||
newSubContent.push({
|
||||
id: node_id,
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
})
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowNodeEndMessage = (data: NodeData) => {
|
||||
const { node_id, input, output, error, elapsed_time, status } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.node_id === node_id)
|
||||
if (filterIndex > -1 && newSubContent[filterIndex].content) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
content: {
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
}
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowCycleMessage = (data: NodeData) => {
|
||||
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === cycle_id)
|
||||
if (filterIndex > -1) {
|
||||
const items = newSubContent[filterIndex].subContent || []
|
||||
items.push({
|
||||
cycle_id,
|
||||
cycle_idx,
|
||||
node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {
|
||||
cycle_idx,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
})
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
subContent: [...items]
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowEndMessage = (data: NodeData) => {
|
||||
const { error, status, audio_url } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status,
|
||||
error,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||
meta_data: { audio_url: audio_url }
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:w-250 rb:p-3 rb:mx-auto">
|
||||
<RbCard
|
||||
title={t('application.test')}
|
||||
headerClassName="rb:min-h-[56px]!"
|
||||
className="rb:h-[calc(100vh-88px)]!"
|
||||
bodyClassName="rb:h-[calc(100%-56px)]! rb:overflow-y-auto rb:px-3! rb:py-0!"
|
||||
>
|
||||
<Chat
|
||||
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
|
||||
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
|
||||
'rb:h-[calc(100%-140px)]': !fileList.length,
|
||||
'rb:h-[calc(100%-208px)]': !!fileList.length,
|
||||
})}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
renderRuntime={application?.type === 'workflow' ? (item, index) => <Runtime item={item} index={index} /> : undefined}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
/>
|
||||
</Chat>
|
||||
</RbCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestChat
|
||||
8
web/src/views/ApplicationConfig/TestChat/type.ts
Normal file
8
web/src/views/ApplicationConfig/TestChat/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import type { Config } from '../types';
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
export interface TestChatProps {
|
||||
application?: Application | null;
|
||||
config: Config | WorkflowConfig | null
|
||||
}
|
||||
189
web/src/views/ApplicationConfig/components/AppSharingModal.tsx
Normal file
189
web/src/views/ApplicationConfig/components/AppSharingModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:19:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 16:03:46
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Checkbox, App, Form } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import { appSharing, getAppShares } from '@/api/application';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import type { AppSharingModalRef, Release } from '../types';
|
||||
import type { SpaceItem } from '@/views/KnowledgeBase/types';
|
||||
import { getWorkspaces } from '@/api/workspaces';
|
||||
import RadioGroupCard from '@/components/RadioGroupCard';
|
||||
|
||||
/** Props for the AppSharingModal component */
|
||||
interface AppSharingModalProps {
|
||||
/** ID of the application being shared */
|
||||
appId: string;
|
||||
/** The release version to share */
|
||||
version: Release | null;
|
||||
}
|
||||
|
||||
const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({ appId, version }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// All workspaces available to share with (excluding the current one)
|
||||
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
|
||||
// IDs of workspaces that already have access to this app
|
||||
const [sharedIds, setSharedIds] = useState<string[]>([]);
|
||||
|
||||
const [form] = Form.useForm<{ target_workspace_ids: string[]; permission: 'readonly' | 'editable' }>();
|
||||
// Reactively track the currently selected workspace IDs in the form
|
||||
const selectedIds: string[] = Form.useWatch('target_workspace_ids', form) ?? [];
|
||||
|
||||
/**
|
||||
* Fetch workspaces and existing share records in parallel,
|
||||
* sort already-shared spaces to the top, then open the modal.
|
||||
* Shows a warning if the user has no shareable workspaces.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
Promise.all([getWorkspaces({ include_current: false }), getAppShares(appId)]).then(([spaces, shared]) => {
|
||||
// Normalise the shared workspace ID field across different API response shapes
|
||||
const ids = ((shared as any[]) || []).map((s: any) => s.workspace_id || s.target_workspace_id || s.id);
|
||||
// Sort: already-shared workspaces appear first
|
||||
const sorted = (spaces as SpaceItem[]).sort((a, b) =>
|
||||
ids.includes(b.id) ? 1 : ids.includes(a.id) ? -1 : 0
|
||||
);
|
||||
setSpaceList(sorted);
|
||||
setSharedIds(ids);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
message.warning(t('application.noShareAuth'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Close the modal and reset form fields */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// Expose open/close handlers to the parent via ref
|
||||
useImperativeHandle(ref, () => ({ handleOpen, handleClose }));
|
||||
|
||||
/**
|
||||
* Toggle a workspace in the selected list.
|
||||
* Already-shared workspaces are read-only and cannot be toggled.
|
||||
*/
|
||||
const handleToggle = (id: string, isShared: boolean) => {
|
||||
if (isShared) return;
|
||||
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
|
||||
form.setFieldValue(
|
||||
'target_workspace_ids',
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
/** Validate the form then submit the sharing request */
|
||||
const handleConfirm = () => {
|
||||
form.validateFields().then(values => {
|
||||
setLoading(true);
|
||||
appSharing(appId, values)
|
||||
.then(() => {
|
||||
message.success(t('common.operateSuccess'));
|
||||
handleClose();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
};
|
||||
|
||||
// Normalise the version label to always start with "v"
|
||||
const versionLabel = version?.version_name
|
||||
? (version.version_name[0].toLowerCase() === 'v' ? version.version_name : `v${version.version_name}`)
|
||||
: `v${version?.version}`;
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.sharingApp')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={<>{t('application.confirmSharing')}({selectedIds.length})</>}
|
||||
onOk={handleConfirm}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ target_workspace_ids: [], permission: 'readonly' }}>
|
||||
{/* Version info: displays version number, release time and publisher */}
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:bg-[#FBFDFF] rb:p-4 rb:mb-4">
|
||||
<div className="rb:text-sm rb:font-medium rb:mb-3">{t('application.VersionInformation')}</div>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-4 rb:text-sm">
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.versionList').replace('列表', '号')}</div>
|
||||
<div className="rb:font-medium">{versionLabel}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.releaseTime')}</div>
|
||||
<div className="rb:font-medium">{formatDateTime(version?.published_at || 0, 'YYYY-MM-DD HH:mm:ss')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.publisher')}</div>
|
||||
<div className="rb:font-medium">{version?.publisher_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
||||
<Form.Item
|
||||
label={t('application.selectTargetSpace')}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name="target_workspace_ids"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
||||
{spaceList.map(space => {
|
||||
const isShared = sharedIds.includes(space.id);
|
||||
return (
|
||||
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
||||
<Checkbox
|
||||
checked={isShared || selectedIds.includes(space.id)}
|
||||
disabled={isShared}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => handleToggle(space.id, isShared)}
|
||||
/>
|
||||
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
||||
{isShared && (
|
||||
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* Permission mode: readonly (use only) or editable (full copy) */}
|
||||
<Form.Item
|
||||
name="permission"
|
||||
label={t('application.permissionMode')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<RadioGroupCard
|
||||
options={['readonly', 'editable'].map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}Mode`),
|
||||
labelDesc: t(`application.${type}ModeDesc`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppSharingModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 17:03:46
|
||||
* @Last Modified time: 2026-03-20 11:38:45
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -12,24 +12,25 @@
|
||||
|
||||
import { type FC, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { App, Flex } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
import type { ChatData, Config } from '../types'
|
||||
import type { ChatData, Config, FeaturesConfigForm } from '../types'
|
||||
import { runCompare, draftRun } from '@/api/application'
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import ChatToolbar from '@/components/Chat/ChatToolbar'
|
||||
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from './VariableList/types'
|
||||
|
||||
|
||||
/**
|
||||
* Component props
|
||||
*/
|
||||
@@ -44,27 +45,44 @@ interface ChatProps {
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
/** Source type: multi-agent cluster or single agent */
|
||||
source?: 'multi_agent' | 'agent';
|
||||
chatVariables?: Variable[]; // Add chatVariables prop
|
||||
/** chatVariables prop */
|
||||
chatVariables?: Variable[];
|
||||
handleEditVariables?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Chat debugging component
|
||||
* Allows testing application with different model configurations side-by-side
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent', chatVariables }) => {
|
||||
const Chat: FC<ChatProps> = ({
|
||||
chatList, data, updateChatList, handleSave, source = 'agent', chatVariables,
|
||||
handleEditVariables
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
setCompareLoading(false)
|
||||
setLoading(false)
|
||||
}, [chatList.map(item => item.label).join(',')])
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.features) setFeatures(data.features)
|
||||
}, [data?.features])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
setFileList([])
|
||||
toolbarRef.current?.setFiles([])
|
||||
setMessage(undefined)
|
||||
}, [source])
|
||||
|
||||
@@ -74,7 +92,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -106,8 +126,8 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => {
|
||||
if (!content || !model_config_id) return
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string) => {
|
||||
if ((!content && !audio_url) || !model_config_id) return
|
||||
updateChatList(prev => {
|
||||
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
|
||||
if (targetIndex !== -1) {
|
||||
@@ -118,12 +138,13 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
modelChatList[targetIndex] = {
|
||||
...modelChatList[targetIndex],
|
||||
conversation_id: conversation_id,
|
||||
conversation_id,
|
||||
list: [
|
||||
...curChatMsgList.slice(0, curChatMsgList.length - 1),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + (content || ''),
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,13 +183,14 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
/** Send message for agent comparison mode */
|
||||
const handleSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
@@ -193,8 +215,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
return
|
||||
}
|
||||
|
||||
addUserMessage(message, fileList)
|
||||
addUserMessage(message, files)
|
||||
setMessage(message)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
|
||||
@@ -202,13 +225,16 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
setCompareLoading(false)
|
||||
|
||||
data.map(item => {
|
||||
const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number };
|
||||
|
||||
const { model_config_id, conversation_id, content, message_length, audio_url } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string };
|
||||
|
||||
switch (item.event) {
|
||||
case 'model_message':
|
||||
updateAssistantMessage(content, model_config_id, conversation_id)
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
break;
|
||||
case 'model_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length, model_config_id)
|
||||
break;
|
||||
case 'compare_end':
|
||||
@@ -219,9 +245,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
runCompare(data.app_id, {
|
||||
runCompare(id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -239,9 +265,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
conversation_id: item.conversation_id
|
||||
})),
|
||||
variables: params,
|
||||
"parallel": true,
|
||||
"stream": true,
|
||||
"timeout": 60,
|
||||
parallel: true,
|
||||
stream: true,
|
||||
timeout: 60,
|
||||
}, handleStreamMessage)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
@@ -265,7 +291,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const assistantMessage: ChatItem = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
created_at: Date.now()
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -277,8 +303,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
if (!content) return
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -298,11 +323,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
/** Update cluster message when error occurs */
|
||||
const updateClusterErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -321,15 +344,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
/** Send message for cluster mode */
|
||||
const handleClusterSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message || message.trim() === '') return
|
||||
addUserMessage(message, fileList)
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addClusterAssistantMessage()
|
||||
|
||||
@@ -338,7 +363,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
@@ -362,13 +387,12 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
draftRun(
|
||||
data.app_id,
|
||||
draftRun(id,
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -403,35 +427,6 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const handleDelete = (index: number) => {
|
||||
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
|
||||
}
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
const isHasLabel = useMemo(() => chatList.some(item => item.label), [chatList])
|
||||
|
||||
return (
|
||||
@@ -444,105 +439,95 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
subTitle={t('application.debuggingEmptyDesc')}
|
||||
className="rb:h-[calc(100vh-159px)]"
|
||||
/>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<Flex key={index} vertical className={clsx({
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F6F6F6] rb:text-center rb:flex-[0_0_auto]"
|
||||
)}>
|
||||
<div className='rb:relative rb:py-2.5 rb:px-3 rb:overflow-hidden'>
|
||||
<div className="rb:text-[#212332] rb:font-medium rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
|
||||
onClick={() => handleDelete(index)}
|
||||
></div>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<Flex key={index} vertical className={clsx({
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F6F6F6] rb:text-center rb:flex-[0_0_auto]"
|
||||
)}>
|
||||
<div className='rb:relative rb:py-2.5 rb:px-3 rb:overflow-hidden'>
|
||||
<div className="rb:text-[#212332] rb:font-medium rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 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>
|
||||
</div>
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mb-3 rb:mt-5': isHasLabel,
|
||||
'rb:mb-3': !isHasLabel,
|
||||
'rb:h-[calc(100vh-292px)]': isCluster,
|
||||
'rb:h-[calc(100vh-353px)]': !isCluster,
|
||||
"rb:pr-4": index !== chatList.length - 1 && chatList.length > 1,
|
||||
"rb:pl-4": index !== 0 && chatList.length > 1,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-100!': chatList.length === 1,
|
||||
'rb:max-w-70!': chatList.length === 2,
|
||||
'rb:max-w-45!': chatList.length === 3,
|
||||
'rb:max-w-24!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty
|
||||
url={ChatIcon}
|
||||
title={t('application.chatEmpty')}
|
||||
isNeedSubTitle={false}
|
||||
size={[240, 200]}
|
||||
className={clsx({
|
||||
"rb:h-[calc(100vh-353px)]": isHasLabel,
|
||||
"rb:h-[calc(100vh-292px)]": !isHasLabel,
|
||||
})}
|
||||
/>}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label || t(`application.ai`)}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mb-3 rb:mt-5': isHasLabel,
|
||||
'rb:mb-3': !isHasLabel,
|
||||
'rb:h-[calc(100vh-292px)]': isCluster,
|
||||
'rb:h-[calc(100vh-353px)]': !isCluster,
|
||||
"rb:pr-4": index !== chatList.length - 1 && chatList.length > 1,
|
||||
"rb:pl-4": index !== 0 && chatList.length > 1,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-100!': chatList.length === 1,
|
||||
'rb:max-w-70!': chatList.length === 2,
|
||||
'rb:max-w-45!': chatList.length === 3,
|
||||
'rb:max-w-24!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty
|
||||
url={ChatIcon}
|
||||
title={t('application.chatEmpty')}
|
||||
isNeedSubTitle={false}
|
||||
size={[240, 200]}
|
||||
className={clsx({
|
||||
"rb:h-[calc(100vh-353px)]": isHasLabel,
|
||||
"rb:h-[calc(100vh-292px)]": !isHasLabel,
|
||||
})}
|
||||
/>}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label || t(`application.ai`)}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:mt-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
onChange={setMessage}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
extra={
|
||||
chatVariables && chatVariables.length > 0 ? (
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': chatVariables.some(vo => vo.required && !vo.value),
|
||||
'rb:border-[#DFE4ED]': !chatVariables.some(vo => vo.required && !vo.value),
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:31:08
|
||||
* @Last Modified time: 2026-03-19 21:21:28
|
||||
*/
|
||||
import { type FC, useRef, useMemo } from 'react';
|
||||
import { type FC, useRef, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Tabs, Dropdown, Button, Flex } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -18,16 +18,21 @@ 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, AgentRef, ClusterRef, WorkflowRef } from '../types'
|
||||
import { deleteApplication } from '@/api/application'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
|
||||
import { deleteApplication, appExport } from '@/api/application'
|
||||
import CopyModal from './CopyModal'
|
||||
import PageHeader from '@/components/Layout/PageHeader'
|
||||
import { exportToYaml } from '@/utils/yamlExport';
|
||||
import FeaturesConfig from './FeaturesConfig'
|
||||
|
||||
/**
|
||||
* Tab keys for application configuration
|
||||
*/
|
||||
const tabKeys = ['arrangement', 'api', 'release', 'statistics']
|
||||
const sharingTabKeys = [
|
||||
'test',
|
||||
// 'log',
|
||||
'api'
|
||||
]
|
||||
|
||||
/**
|
||||
* Menu icon mapping
|
||||
@@ -55,6 +60,10 @@ interface ConfigHeaderProps {
|
||||
workflowRef: React.RefObject<WorkflowRef>
|
||||
/** App component ref (Agent/Cluster/Workflow) */
|
||||
appRef?: React.RefObject<AgentRef | ClusterRef | WorkflowRef>
|
||||
/** Features config from parent state */
|
||||
features?: FeaturesConfigForm;
|
||||
/** Callback to update features in parent */
|
||||
onFeaturesChange?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,35 +73,45 @@ interface ConfigHeaderProps {
|
||||
const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
application, activeTab, handleChangeTab, refresh,
|
||||
workflowRef,
|
||||
appRef,
|
||||
features,
|
||||
onFeaturesChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const copyModalRef = useRef<CopyModalRef>(null);
|
||||
|
||||
/**
|
||||
* Format tab items for display
|
||||
*/
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
const formatTabItems = useMemo(() => {
|
||||
return (source === 'sharing' ? sharingTabKeys : tabKeys).map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
}))
|
||||
}
|
||||
}, [source, sharingTabKeys, tabKeys])
|
||||
/**
|
||||
* Handle menu item click
|
||||
*/
|
||||
const handleClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (!application) return
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
applicationModalRef.current?.handleOpen(application as Application)
|
||||
applicationModalRef.current?.handleOpen(application)
|
||||
break;
|
||||
case 'copy':
|
||||
copyModalRef.current?.handleOpen()
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
copyModalRef.current?.handleOpen()
|
||||
})
|
||||
break;
|
||||
case 'export':
|
||||
exportToYaml(workflowRef?.current?.config, application?.name ? `${application?.name}.yml` : undefined)
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
appExport(application.id, application.name)
|
||||
})
|
||||
break;
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
@@ -151,7 +170,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
* Format dropdown menu items
|
||||
*/
|
||||
const formatMenuItems = useMemo(() => {
|
||||
const items = (application?.type === 'workflow' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
key,
|
||||
icon: <img src={menuIcons[key]} className="rb:w-4 rb:h-4 rb:mr-2" />,
|
||||
label: t(`common.${key}`),
|
||||
@@ -159,7 +178,11 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
return items
|
||||
}, [t, handleClick, application])
|
||||
|
||||
console.log('formatMenuItems', formatMenuItems)
|
||||
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
|
||||
appRef?.current?.handleSaveFeaturesConfig?.(value)
|
||||
onFeaturesChange?.(value)
|
||||
}, [appRef, onFeaturesChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -170,8 +193,8 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
'rb:bg-[#171719]': application?.type === 'workflow',
|
||||
})}
|
||||
title={application?.name || ''}
|
||||
operation={<Dropdown
|
||||
menu={{ items: formatMenuItems, onClick: handleClick }}
|
||||
operation={source !== 'sharing' && <Dropdown
|
||||
menu={{ items: formatMenuItems, onClick: handleClick }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
@@ -182,13 +205,14 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
centerContent={<Flex justify="center" className="rb:h-16!">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
items={formatTabItems()}
|
||||
items={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
className={styles.tabs}
|
||||
/>
|
||||
</Flex>}
|
||||
extra={application?.type === 'workflow'
|
||||
extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement'
|
||||
? <Flex align="center" justify="end" gap={10} className="rb:h-8">
|
||||
<FeaturesConfig source={application?.type} value={features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button onClick={clear}>{t('workflow.clear')}</Button>
|
||||
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
|
||||
<Button onClick={run}>{t('workflow.run')}</Button>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:14
|
||||
*/
|
||||
/**
|
||||
* Copy Application Modal
|
||||
* Allows users to duplicate an existing application with a new name
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import FileUploadSettingModal from './FileUploadSettingModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
interface FeaturesConfigModalProps {
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for copying applications
|
||||
*/
|
||||
const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({
|
||||
refresh,
|
||||
source,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FeaturesConfigForm>();
|
||||
const values = Form.useWatch([], form)
|
||||
const fileUploadSettingModalRef = useRef<any>(null)
|
||||
|
||||
/** Close modal and reset form */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
/** Open modal */
|
||||
const handleOpen = (initValue: FeaturesConfigForm) => {
|
||||
setVisible(true);
|
||||
console.log('initValue', initValue)
|
||||
form.setFieldsValue(initValue)
|
||||
};
|
||||
/** Copy application with new name */
|
||||
const handleSave = () => {
|
||||
setVisible(false);
|
||||
refresh(form.getFieldsValue())
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
fileUploadSettingModalRef.current?.handleOpen(values?.file_upload)
|
||||
}
|
||||
|
||||
const handleSaveSettings = (settings: FeaturesConfigForm['file_upload']) => {
|
||||
form.setFieldValue('file_upload', { ...settings, enabled: values?.file_upload?.enabled ?? false })
|
||||
}
|
||||
|
||||
/** Expose methods to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RbModal
|
||||
title={t('application.features')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.confirm')}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{source !== 'workflow' && <>
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t(`memoryConversation.web_search`)}
|
||||
name={['web_search', "enabled"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.text_to_speech')}
|
||||
name={['text_to_speech', "enabled"]}
|
||||
desc={t('application.text_to_speech_desc')}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.file_upload')}
|
||||
name={['file_upload', "enabled"]}
|
||||
desc={values?.file_upload?.enabled ? undefined : t('application.file_upload_desc')}
|
||||
/>
|
||||
{values?.file_upload?.enabled && (() => {
|
||||
const fu = values.file_upload
|
||||
const types = [
|
||||
{ type: 'image', enabled: fu.image_enabled, maxSize: fu.image_max_size_mb },
|
||||
{ type: 'audio', enabled: fu.audio_enabled, maxSize: fu.audio_max_size_mb },
|
||||
{ type: 'document', enabled: fu.document_enabled, maxSize: fu.document_max_size_mb },
|
||||
{ type: 'video', enabled: fu.video_enabled, maxSize: fu.video_max_size_mb },
|
||||
].filter(item => item.enabled)
|
||||
return types.length > 0 ? <>
|
||||
<Flex gap={12} className="rb:py-2!">
|
||||
<div className="rb:flex-1 rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:bg-white rb:text-[12px]">
|
||||
<div className="rb:grid rb:grid-cols-2 rb:gap-2 rb:text-[12px] rb:text-[#5B6167] rb:border-b rb:border-b-[#DFE4ED]">
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.supportedTypes`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{t('application.singleMaxSize')}</div>
|
||||
</div>
|
||||
{types.map((item, index) => (
|
||||
<div key={item.type} className={clsx('rb:grid rb:grid-cols-2 rb:gap-2', {
|
||||
'rb:border-b rb:border-b-[#DFE4ED]': index !== types.length - 1
|
||||
})}>
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.${item.type}`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{item.maxSize} MB</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:py-1">{t('application.maxCount')}</div>
|
||||
{fu.max_file_count} {t('application.unix')}
|
||||
</div>
|
||||
</Flex>
|
||||
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
</> : <Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
})()}
|
||||
<Form.Item name="file_upload" hidden />
|
||||
</div>
|
||||
</Flex>
|
||||
</Form>
|
||||
</RbModal>
|
||||
|
||||
<FileUploadSettingModal
|
||||
ref={fileUploadSettingModalRef}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesConfigModal;
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 15:18:20
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import type { FeaturesConfigForm } from '../../types'
|
||||
|
||||
type FileUpload = Omit<FeaturesConfigForm['file_upload'], 'settings'>
|
||||
|
||||
interface FileUploadSettingModalRef {
|
||||
handleOpen: (values?: FileUpload) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface FileUploadSettingModalProps {
|
||||
onSave: (values: FileUpload) => void;
|
||||
}
|
||||
|
||||
const fileTypeOptions = [
|
||||
{
|
||||
type: 'document',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||
formats: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xls",
|
||||
"txt",
|
||||
"csv",
|
||||
"json",
|
||||
"md",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||
formats: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||
formats: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||
formats: [
|
||||
"mp4",
|
||||
"mov",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: FileUpload = {
|
||||
enabled: false,
|
||||
image_enabled: false,
|
||||
image_max_size_mb: 20,
|
||||
image_allowed_extensions: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
audio_enabled: false,
|
||||
audio_max_size_mb: 50,
|
||||
audio_allowed_extensions: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"flac"
|
||||
],
|
||||
document_enabled: false,
|
||||
document_max_size_mb: 100,
|
||||
document_allowed_extensions: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"xlsx",
|
||||
"txt",
|
||||
"csv",
|
||||
"json"
|
||||
],
|
||||
video_enabled: false,
|
||||
video_max_size_mb: 100,
|
||||
video_allowed_extensions: [
|
||||
"mp4",
|
||||
"mov",
|
||||
"avi",
|
||||
"webm"
|
||||
],
|
||||
max_file_count: 5,
|
||||
allowed_transfer_methods: 'both'
|
||||
}
|
||||
|
||||
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
|
||||
onSave,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FileUpload>();
|
||||
const values = Form.useWatch([], form)
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleOpen = (values?: FileUpload) => {
|
||||
setVisible(true);
|
||||
if (values) {
|
||||
const methods = values.allowed_transfer_methods || ['local_file', 'remote_url']
|
||||
const transferMethod = Array.isArray(methods)
|
||||
? methods.length === 2 ? 'both' : methods[0]
|
||||
: methods
|
||||
form.setFieldsValue({ ...values, allowed_transfer_methods: transferMethod as any })
|
||||
} else {
|
||||
form.setFieldsValue(defaultValues)
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const vals = await form.validateFields();
|
||||
const methodMap: Record<string, string[]> = {
|
||||
local_file: ['local_file'],
|
||||
remote_url: ['remote_url'],
|
||||
both: ['local_file', 'remote_url'],
|
||||
}
|
||||
onSave({ ...vals, allowed_transfer_methods: methodMap[vals.allowed_transfer_methods as unknown as string] ?? [] });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.settings')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={defaultValues}>
|
||||
<Form.Item
|
||||
label={t('application.uploadType')}
|
||||
name="allowed_transfer_methods"
|
||||
>
|
||||
<Radio.Group block buttonStyle="solid">
|
||||
<Radio.Button value="local_file">{t('application.local')}</Radio.Button>
|
||||
<Radio.Button value="remote_url">URL</Radio.Button>
|
||||
<Radio.Button value="both">{t('application.both')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
|
||||
<Form.Item label={t('application.maxCount')} name="max_file_count">
|
||||
<InputNumber min={1} max={20} precision={0} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('application.supportedTypes')}>
|
||||
<Flex vertical gap={12}>
|
||||
{fileTypeOptions.map((option) => {
|
||||
const enabledKey = `${option.type}_enabled` as keyof FileUpload
|
||||
const sizeKey = `${option.type}_max_size_mb` as keyof FileUpload
|
||||
const isEnabled = values?.[enabledKey]
|
||||
return (
|
||||
<div
|
||||
key={option.type}
|
||||
className={clsx('rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3', {
|
||||
'rb:bg-[#f5f7fc]': isEnabled
|
||||
})}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col flex="36px" className="rb:self-center">{option.icon}</Col>
|
||||
<Col flex="1">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex vertical>
|
||||
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats.map(item => item.toUpperCase()).join(', ')}</div>
|
||||
</Flex>
|
||||
<Form.Item name={enabledKey} valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
{isEnabled && (
|
||||
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
|
||||
<div>{t('application.singleMaxSize')}: </div>
|
||||
<Form.Item name={sizeKey} noStyle>
|
||||
<InputNumber min={1} max={100} suffix="MB" className="rb:flex-1" />
|
||||
</Form.Item>
|
||||
<Form.Item name={`${option.type}_allowed_extensions`} hidden />
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUploadSettingModal;
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:20:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:59
|
||||
*/
|
||||
import { type FC, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import FeaturesConfigModal from './FeaturesConfigModal'
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
/** Props for the FeaturesConfig component */
|
||||
interface FeaturesConfigProps {
|
||||
/** Current feature configuration values */
|
||||
value: FeaturesConfigForm;
|
||||
/** Callback to propagate updated config back to the parent */
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
|
||||
const FeaturesConfig: FC<FeaturesConfigProps> = ({
|
||||
value,
|
||||
refresh,
|
||||
source
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Ref used to imperatively open the config modal
|
||||
const funConfigModalRef = useRef<FeaturesConfigModalRef>(null)
|
||||
|
||||
/** Open the feature config modal pre-populated with the current values */
|
||||
const handleFeaturesConfig = () => {
|
||||
console.log('handleFeaturesConfig', value)
|
||||
funConfigModalRef.current?.handleOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Button that triggers the feature configuration modal */}
|
||||
<Button onClick={handleFeaturesConfig}>{t('application.features')}</Button>
|
||||
|
||||
{/* Modal for editing feature settings; calls refresh on save */}
|
||||
<FeaturesConfigModal
|
||||
ref={funConfigModalRef}
|
||||
refresh={refresh}
|
||||
source={source}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturesConfig
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:15:39
|
||||
* @Last Modified time: 2026-03-19 21:22:53
|
||||
*/
|
||||
/**
|
||||
* Tool List Component
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import Empty from '@/components/Empty'
|
||||
import ToolModal from './ToolModal'
|
||||
import { getToolMethods, getToolDetail } from '@/api/tools'
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
/**
|
||||
* Tool list management component
|
||||
@@ -47,18 +48,19 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: mcpFilterItem?.description,
|
||||
method_id: mcpFilterItem?.method_id,
|
||||
value: mcpFilterItem?.name,
|
||||
description: mcpFilterItem?.description,
|
||||
parameters: mcpFilterItem?.parameters
|
||||
}
|
||||
break
|
||||
case 'builtin':
|
||||
if ((methods as any[]).length > 1) {
|
||||
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: builtinFilterItem?.description,
|
||||
method_id: builtinFilterItem?.method_id,
|
||||
value: builtinFilterItem?.name,
|
||||
@@ -68,17 +70,18 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: (methods as any[])[0]?.description,
|
||||
method_id: (methods as any[])[0]?.method_id,
|
||||
value: (methods as any[])[0]?.name,
|
||||
description: (methods as any[])[0]?.description,
|
||||
parameters: (methods as any[])[0]?.parameters
|
||||
}
|
||||
break
|
||||
default:
|
||||
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: customFilterItem?.name,
|
||||
method_id: customFilterItem?.method_id,
|
||||
value: customFilterItem?.name,
|
||||
@@ -103,7 +106,10 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
/** Add new tool to list */
|
||||
const updateTools = (tool: ToolOption) => {
|
||||
const list = [...toolList, tool]
|
||||
const list = [...toolList, {
|
||||
...tool,
|
||||
is_active: true,
|
||||
}]
|
||||
setToolList(list)
|
||||
onChange && onChange(list)
|
||||
}
|
||||
@@ -142,8 +148,13 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
: <Flex vertical gap={12}>
|
||||
{toolList.map((item, index) => (
|
||||
<Flex key={index} align="center" justify="space-between" className="rb:py-2.5! rb:pl-4! rb:pr-3! rb-border rb:rounded-lg">
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
</div>
|
||||
<Tag color={item.is_active ? 'success' : 'error'} className="rb:mt-1">
|
||||
{item.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Space size={12}>
|
||||
<Switch size="small" checked={item.enabled} onChange={() => handleChangeEnabled(index)} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-17 15:50:48
|
||||
*/
|
||||
/**
|
||||
* Type definitions for tool configuration in application settings
|
||||
@@ -32,6 +32,7 @@ export interface ToolOption {
|
||||
tool_id?: string;
|
||||
/** Whether tool is enabled */
|
||||
enabled?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:37
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 18:57:36
|
||||
* @Last Modified time: 2026-03-19 21:09:32
|
||||
*/
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import ConfigHeader from './components/ConfigHeader'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef } from './types'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef, Config } 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 { getApplication, getApplicationConfig, getMultiAgentConfig, getWorkflowConfig } from '@/api/application'
|
||||
import Workflow from '@/views/Workflow';
|
||||
import Statistics from './Statistics'
|
||||
import TestChat from './TestChat'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
/**
|
||||
* Application configuration page component
|
||||
@@ -25,7 +27,7 @@ import Statistics from './Statistics'
|
||||
*/
|
||||
const ApplicationConfig: React.FC = () => {
|
||||
// Hooks
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
|
||||
// Refs for different application types
|
||||
const agentRef = useRef<AgentRef>(null)
|
||||
@@ -35,6 +37,32 @@ const ApplicationConfig: React.FC = () => {
|
||||
// State
|
||||
const [application, setApplication] = useState<Application | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('arrangement');
|
||||
const [features, setFeatures] = useState<import('./types').FeaturesConfigForm | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
||||
}, [source])
|
||||
|
||||
const [config, setConfig] = useState<Config | WorkflowConfig | null>(null)
|
||||
useEffect(() => {
|
||||
if (source === 'sharing' && application?.type) {
|
||||
getAppConfig()
|
||||
}
|
||||
}, [source, application?.type])
|
||||
|
||||
const getAppConfig = () => {
|
||||
if (!id || !source || !application?.type) {
|
||||
return
|
||||
}
|
||||
const request = application?.type === 'agent'
|
||||
? getApplicationConfig
|
||||
: application?.type === 'multi_agent'
|
||||
? getMultiAgentConfig
|
||||
: getWorkflowConfig
|
||||
request(id as string).then(res => {
|
||||
setConfig(res as Config | WorkflowConfig | null)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab change with auto-save for arrangement tab
|
||||
@@ -87,14 +115,17 @@ const ApplicationConfig: React.FC = () => {
|
||||
refresh={getApplicationInfo}
|
||||
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
|
||||
workflowRef={workflowRef}
|
||||
features={features}
|
||||
onFeaturesChange={setFeatures}
|
||||
/>
|
||||
<div className="rb:p-3 rb:max-h-[calc(100vh-65px)] rb:overflow-auto">
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'api' && <Api application={application} />}
|
||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||
{activeTab === 'statistics' && <Statistics application={application} />}
|
||||
{activeTab === 'test' && <TestChat application={application} config={config} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 18:55:57
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 21:10:53
|
||||
*/
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
@@ -77,6 +77,8 @@ export interface Config extends MultiAgentConfig {
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
skills?: SkillConfigForm | null;
|
||||
|
||||
features?: FeaturesConfigForm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +129,8 @@ export interface AgentRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +142,8 @@ export interface ClusterRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +162,8 @@ export interface WorkflowRef {
|
||||
/** Add variable */
|
||||
addVariable: () => void;
|
||||
config: WorkflowConfig | null;
|
||||
features: WorkflowConfig['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,4 +410,72 @@ export interface StatisticsData {
|
||||
total_api_calls: number;
|
||||
/** Total tokens used */
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface FileTypeConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
maxCount: number;
|
||||
maxSize: number;
|
||||
}
|
||||
interface FileSetttings {
|
||||
image_enabled: boolean;
|
||||
image_max_size_mb: number;
|
||||
image_allowed_extensions: string[];
|
||||
audio_enabled: boolean;
|
||||
audio_max_size_mb: number;
|
||||
audio_allowed_extensions: string[];
|
||||
document_enabled: boolean;
|
||||
document_max_size_mb: number;
|
||||
document_allowed_extensions: string[];
|
||||
video_enabled: boolean;
|
||||
video_max_size_mb: number;
|
||||
video_allowed_extensions: string[];
|
||||
max_file_count: number;
|
||||
allowed_transfer_methods: string[] | string;
|
||||
}
|
||||
export type FeaturesConfigForm = {
|
||||
file_upload: FileSetttings & {
|
||||
enabled: boolean;
|
||||
settings?: FileSetttings
|
||||
};
|
||||
opening_statement: {
|
||||
enabled: boolean;
|
||||
statement: string | null;
|
||||
suggested_questions: string[];
|
||||
};
|
||||
suggested_questions_after_answer: {
|
||||
enabled: boolean;
|
||||
};
|
||||
text_to_speech: {
|
||||
enabled: boolean;
|
||||
voice: string | null;
|
||||
language: string | null;
|
||||
autoplay: boolean;
|
||||
};
|
||||
citation: {
|
||||
enabled: boolean;
|
||||
};
|
||||
web_search: {
|
||||
enabled: boolean;
|
||||
search_engine: string | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Function config modal ref methods
|
||||
*/
|
||||
export interface FeaturesConfigModalRef {
|
||||
/** Open function config modal */
|
||||
handleOpen: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* App sharing modal ref methods
|
||||
*/
|
||||
export interface AppSharingModalRef {
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface AppSharingForm {
|
||||
target_workspace_ids: string[];
|
||||
permission: 'readonly' | 'editable'
|
||||
}
|
||||
168
web/src/views/ApplicationManagement/MySharing.tsx
Normal file
168
web/src/views/ApplicationManagement/MySharing.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 16:15:43
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { MySharedOutItem } from './types';
|
||||
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||
|
||||
const MySharing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { modal } = App.useApp();
|
||||
const [data, setData] = useState<MySharedOutItem[]>([])
|
||||
|
||||
useEffect(() => { getList() }, [])
|
||||
|
||||
const getList = () => {
|
||||
mySharedOutList()
|
||||
.then(res => setData(res as MySharedOutItem[]))
|
||||
}
|
||||
|
||||
/** Group items by target_workspace_id */
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { workspace: Pick<MySharedOutItem, 'target_workspace_id' | 'target_workspace_name' | 'target_workspace_icon'>, items: MySharedOutItem[] }>();
|
||||
data.forEach(item => {
|
||||
if (!map.has(item.target_workspace_id)) {
|
||||
map.set(item.target_workspace_id, {
|
||||
workspace: {
|
||||
target_workspace_id: item.target_workspace_id,
|
||||
target_workspace_name: item.target_workspace_name,
|
||||
target_workspace_icon: item.target_workspace_icon,
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
map.get(item.target_workspace_id)!.items.push(item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [data]);
|
||||
|
||||
const handleAllCancel = (workspace: { target_workspace_name: string; target_workspace_id: string; }) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmWorkspaceCancelShareDesc', { workspace: workspace.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelSpaceShare(workspace.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
modal.confirm({
|
||||
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelShare(item.source_app_id, item.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: MySharedOutItem) => {
|
||||
let url = `/#/application/config/${item.source_app_id}`
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
|
||||
<BodyWrapper loading={false} empty={data.length === 0}>
|
||||
{grouped.map(({ workspace, items }) => (
|
||||
<Collapse
|
||||
key={workspace.target_workspace_id}
|
||||
defaultActiveKey={[workspace.target_workspace_id]}
|
||||
items={[{
|
||||
key: workspace.target_workspace_id,
|
||||
label: (
|
||||
<Flex align="center" gap={12}>
|
||||
{workspace.target_workspace_icon
|
||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{workspace.target_workspace_name[0]}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||
>
|
||||
{t('application.allCancel')}
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
<div
|
||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
|
||||
onClick={(e) => handleCancelOne(item, e)}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{item.source_app_name[0]}
|
||||
</div>
|
||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||
</Flex>
|
||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||
})}>
|
||||
{t(`application.${item.source_app_type}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||
<span>{item.source_app_version}</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||
})}>
|
||||
{t(`application.${item.permission}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</BodyWrapper>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MySharing;
|
||||
256
web/src/views/ApplicationManagement/components/UploadModal.tsx
Normal file
256
web/src/views/ApplicationManagement/components/UploadModal.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 17:19:46
|
||||
*/
|
||||
/**
|
||||
* UploadModal Component
|
||||
*
|
||||
* This component provides a modal for uploading workflow files with a multi-step process:
|
||||
* 1. Upload - Select platform and file
|
||||
* 2. Complex - Show warnings and errors if any
|
||||
* 3. SureInfo - Confirm and edit workflow information
|
||||
* 4. Completed - Show success message and options
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Steps, Flex, Alert, Button, Result, message } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Application, UploadModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import UploadFiles from '@/components/Upload/UploadFiles'
|
||||
import { appImport } from '@/api/application'
|
||||
|
||||
/**
|
||||
* Props for UploadModal component
|
||||
*/
|
||||
interface UploadModalProps {
|
||||
/** Function to refresh the parent component after workflow import */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Steps definition for the upload process
|
||||
*/
|
||||
const steps = [
|
||||
'upload', // Step 1: File upload
|
||||
'complex', // Step 2: Error/warning display
|
||||
'completed' // Step 4: Success message
|
||||
]
|
||||
/**
|
||||
* UploadModal component
|
||||
*
|
||||
* @param {UploadModalProps} props - Component props
|
||||
* @param {React.Ref<UploadModalRef>} ref - Ref for imperative methods
|
||||
*/
|
||||
const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State management
|
||||
const [visible, setVisible] = useState(false); // Modal visibility
|
||||
const [form] = Form.useForm<{ file: File[] }>(); // Form instance
|
||||
const [loading, setLoading] = useState(false); // Loading state
|
||||
const [current, setCurrent] = useState<number>(0); // Current step
|
||||
const [appId, setAppId] = useState<string | null>(null); // Imported application ID
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
|
||||
/**
|
||||
* Handle modal close
|
||||
* Resets all states and form fields
|
||||
*/
|
||||
const handleClose = () => {
|
||||
refresh()
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setCurrent(0);
|
||||
setAppId(null);
|
||||
setLoading(false);
|
||||
setWarnings([])
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle modal open
|
||||
* Resets form fields and shows modal
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
form.resetFields();
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle save/submit action
|
||||
* Processes different logic based on current step
|
||||
*/
|
||||
const handleSave = () => {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
switch(current) {
|
||||
case 0: // Step 1: Upload file
|
||||
if (!values.file || values.file.length === 0) {
|
||||
message.warning(t('application.pleaseUploadFile'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', values.file[0]);
|
||||
|
||||
setLoading(true)
|
||||
// Call import API
|
||||
appImport(formData)
|
||||
.then(res => {
|
||||
const { warnings, app } = res as { warnings: string[]; app: Application };
|
||||
|
||||
setAppId(app?.id)
|
||||
if (warnings.length) {
|
||||
setCurrent(1)
|
||||
setWarnings(warnings)
|
||||
} else {
|
||||
setCurrent(2)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
/**
|
||||
* Handle navigation after successful import
|
||||
* @param {string} type - Navigation type ('detail' or 'list')
|
||||
*/
|
||||
const handleJump = (type: string) => {
|
||||
handleClose();
|
||||
refresh();
|
||||
setTimeout(() => {
|
||||
switch (type) {
|
||||
case 'detail':
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
break;
|
||||
}
|
||||
}, 100)
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate modal footer based on current step
|
||||
*/
|
||||
const getFooter = useMemo(() => {
|
||||
switch (current) {
|
||||
case 0: // Step 1: Upload
|
||||
return [
|
||||
<Button key="back" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [current, loading]);
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.import')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.confirm')}
|
||||
onOk={handleSave}
|
||||
footer={getFooter}
|
||||
>
|
||||
{/* Steps indicator */}
|
||||
<div className='rb:p-3 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:mb-3'>
|
||||
<Steps
|
||||
labelPlacement="vertical"
|
||||
size="small"
|
||||
current={current}
|
||||
items={steps.map(key => ({ title: t(`application.${key}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{current === 0 &&
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="file"
|
||||
valuePropName="fileList"
|
||||
noStyle
|
||||
>
|
||||
<UploadFiles
|
||||
isAutoUpload={false}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
maxCount={1}
|
||||
fileType={['yml']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
{/* Step 2: Error/warning display */}
|
||||
{current === 1 &&
|
||||
<Flex vertical gap={12}>
|
||||
{warnings.map((vo, index) => (
|
||||
<Alert
|
||||
key={index}
|
||||
message={<div>{vo}</div>}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
{current === 2 &&
|
||||
<Result
|
||||
status="success"
|
||||
title={t('application.importSuccess')}
|
||||
subTitle={t('application.importSuccessDesc')}
|
||||
extra={[
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
}
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 12:05:46
|
||||
* @Last Modified time: 2026-03-12 17:19:33
|
||||
*/
|
||||
/**
|
||||
* UploadWorkflowModal Component
|
||||
@@ -72,6 +72,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
setFirstFormData(null);
|
||||
setAppId(null);
|
||||
setLoading(false);
|
||||
refresh()
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:44:29
|
||||
* @Last Modified time: 2026-03-19 21:29:45
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -10,23 +10,28 @@
|
||||
* Supports creating, editing, and deleting applications
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { App, Select, Space, Form, Flex, Dropdown, Button } from 'antd';
|
||||
import { Button, App, Select, Space, Dropdown, type SegmentedProps, Flex, Form } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ApplicationModal, { types } from './components/ApplicationModal';
|
||||
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { getApplicationListUrl, deleteApplication } from '@/api/application'
|
||||
import { getApplicationListUrl, deleteApplication, copyApplication } from '@/api/application'
|
||||
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import UploadWorkflowModal from './components/UploadWorkflowModal'
|
||||
import UploadModal from './components/UploadModal'
|
||||
import PageTabs from '@/components/PageTabs'
|
||||
import MySharing from './MySharing'
|
||||
import RbCard from '@/components/RbCard'
|
||||
import RbButton from '@/components/RbButton'
|
||||
import RbDescriptions from '@/components/RbDescriptions'
|
||||
|
||||
|
||||
const tabKeys = ['apps', 'sharing', 'myShare']
|
||||
/**
|
||||
* Application management main component
|
||||
*/
|
||||
@@ -37,16 +42,16 @@ const ApplicationManagement: React.FC = () => {
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const uploadModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
const [form] = Form.useForm<Query>()
|
||||
const query = Form.useWatch([], form)
|
||||
const [activeTab, setActiveTab] = useState('apps');
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
const data = Object.fromEntries(searchParams)
|
||||
const { type } = data
|
||||
|
||||
form.setFieldValue('type', type || null)
|
||||
form.setFieldValue('type', type || undefined)
|
||||
}, [searchParams])
|
||||
|
||||
/** Refresh application list */
|
||||
@@ -60,7 +65,11 @@ const ApplicationManagement: React.FC = () => {
|
||||
}
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: Application) => {
|
||||
window.open(`/#/application/config/${item.id}`);
|
||||
let url = `/#/application/config/${item.id}`
|
||||
if (item.is_shared) {
|
||||
url += `/${activeTab}`
|
||||
}
|
||||
window.open(url);
|
||||
}
|
||||
/** Delete application with confirmation */
|
||||
const handleDelete = (item: Application) => {
|
||||
@@ -89,92 +98,139 @@ const ApplicationManagement: React.FC = () => {
|
||||
case 'thirdParty':
|
||||
handleImport()
|
||||
break;
|
||||
case 'import':
|
||||
uploadModalRef.current?.handleOpen()
|
||||
}
|
||||
}
|
||||
const formatTabItems = useMemo(() => {
|
||||
return tabKeys.map(value => ({
|
||||
value,
|
||||
label: t(`application.${value}`),
|
||||
}))
|
||||
}, [tabKeys, t])
|
||||
/** Handle tab change */
|
||||
const handleChangeTab = (value: SegmentedProps['value']) => {
|
||||
setActiveTab(value as string);
|
||||
form.resetFields()
|
||||
}
|
||||
const handleCopy = (item: Application) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmCopyDesc', { app: item.name }),
|
||||
okText: t('common.copy'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
copyApplication(item.id)
|
||||
.then(() => {
|
||||
setActiveTab('apps')
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form form={form} className="rb:mb-4!">
|
||||
<Flex justify="space-between">
|
||||
<Space size={10}>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={[
|
||||
{ value: null, label: t('application.allType') },
|
||||
...types.map((type) => ({
|
||||
<Flex justify="space-between" className="rb:mb-4!">
|
||||
<PageTabs
|
||||
value={activeTab}
|
||||
options={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
>
|
||||
{activeTab !== 'myShare' &&
|
||||
<Space size={8}>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={(activeTab === 'sharing' ? types.filter(type => type !== 'multi_agent') : types).map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))
|
||||
]}
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
className="rb:w-75!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={10}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
], onClick: handleClick
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<RbButton type="primary" icon={<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/plus.svg')]"></div>} onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</RbButton>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Form>
|
||||
}))}
|
||||
allowClear
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
className="rb:w-75!"
|
||||
/>
|
||||
</Form.Item>
|
||||
{activeTab === 'apps' && <Space size={10}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
{ key: 'import', label: t('application.import') },
|
||||
], onClick: handleClick
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<RbButton type="primary" icon={<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/plus.svg')]"></div>} onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</RbButton>
|
||||
</Space>}
|
||||
</Space>
|
||||
}
|
||||
</Form>
|
||||
</Flex>
|
||||
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
query={query}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatarText={item.name.trim()[0]}
|
||||
avatarClassName={clsx({
|
||||
'rb:bg-[#155EEF]': item.type === 'agent',
|
||||
'rb:bg-[#9C6FFF]!': item.type === 'multi_agent',
|
||||
'rb:bg-[#171719]': item.type === 'workflow',
|
||||
})}
|
||||
footer={<Flex justify="space-between" gap={12}>
|
||||
<RbButton danger className="rb:w-22.25" onClick={() => handleDelete(item)}>{t('common.delete')}</RbButton>
|
||||
<RbButton type="primary" ghost className="rb:flex-1" onClick={() => handleEdit(item)}>{t('application.configuration')}</RbButton>
|
||||
</Flex>}
|
||||
>
|
||||
<RbDescriptions
|
||||
items={['type', 'source', 'created_at'].map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
children: <span className={clsx('rb:font-medium', {
|
||||
'rb:text-[#155EEF]': key === 'type',
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? t('application.shared')
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
}))}
|
||||
/>
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
{(activeTab === 'apps' || activeTab === 'sharing') &&
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
needLoading={false}
|
||||
query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatarText={item.name.trim()[0]}
|
||||
avatarClassName={clsx({
|
||||
'rb:bg-[#155EEF]': item.type === 'agent',
|
||||
'rb:bg-[#9C6FFF]!': item.type === 'multi_agent',
|
||||
'rb:bg-[#171719]': item.type === 'workflow',
|
||||
})}
|
||||
footer={
|
||||
item.is_shared
|
||||
? <Flex justify="space-between" gap={12}>
|
||||
<RbButton type="primary" ghost block onClick={() => handleEdit(item)}>{t('common.view')}</RbButton>
|
||||
{item.share_permission === 'editable' && <RbButton type="primary" className="rb:w-[calc(100%-46px)]" onClick={() => handleCopy(item)}>{t('common.copy')}</RbButton>}
|
||||
</Flex>
|
||||
: <Flex justify="space-between" gap={12}>
|
||||
<RbButton danger className="rb:w-22.25" onClick={() => handleDelete(item)}>{t('common.delete')}</RbButton>
|
||||
<RbButton type="primary" ghost className="rb:flex-1" onClick={() => handleEdit(item)}>{t('application.configuration')}</RbButton>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<RbDescriptions
|
||||
items={['type', 'source', 'created_at'].map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
children: <span className={clsx('rb:font-medium', {
|
||||
'rb:text-[#155EEF]': key === 'type',
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? t('application.shared')
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
}))}
|
||||
/>
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{activeTab === 'myShare' && <MySharing />}
|
||||
|
||||
|
||||
<ApplicationModal
|
||||
ref={applicationModalRef}
|
||||
@@ -185,6 +241,10 @@ const ApplicationManagement: React.FC = () => {
|
||||
ref={uploadWorkflowModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
<UploadModal
|
||||
ref={uploadModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:16:03
|
||||
* @Last Modified time: 2026-03-18 10:50:27
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Application Management
|
||||
@@ -15,6 +15,8 @@ export interface Query {
|
||||
/** Search keyword */
|
||||
search: string;
|
||||
type?: string;
|
||||
shared_only?: boolean;
|
||||
include_shared?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,11 @@ export interface Application {
|
||||
created_at: number;
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
share_permission?: string;
|
||||
source_workspace_name?: string;
|
||||
source_workspace_icon?: string;
|
||||
source_app_version?: string;
|
||||
source_app_is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,4 +240,28 @@ export interface UploadData extends WorkflowConfig {
|
||||
export interface UploadWorkflowModalRef {
|
||||
/** Open the upload workflow modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload app modal ref interface
|
||||
*/
|
||||
export interface UploadModalRef {
|
||||
/** Open the upload workflow modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface MySharedOutItem {
|
||||
id: string;
|
||||
source_app_id: string;
|
||||
source_workspace_id: string;
|
||||
target_workspace_id: string;
|
||||
shared_by: string;
|
||||
permission: 'readonly' | 'editable';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
source_app_name: string;
|
||||
source_app_type: string;
|
||||
source_app_version: string;
|
||||
source_app_is_active: boolean;
|
||||
target_workspace_name: string;
|
||||
target_workspace_icon: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 15:03:31
|
||||
* @Last Modified time: 2026-03-19 21:32:34
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -19,8 +19,8 @@
|
||||
* - File list management
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import { Upload, Progress, App, Flex } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
@@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
@@ -48,14 +49,14 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
disabled?: boolean;
|
||||
/** File size limit in MB */
|
||||
fileSize?: number;
|
||||
/** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
|
||||
fileType?: string[];
|
||||
/** Auto-upload on file selection, default is true */
|
||||
isAutoUpload?: boolean;
|
||||
/** Maximum number of files allowed */
|
||||
maxCount?: number;
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
const transform_file_type = {
|
||||
@@ -70,6 +71,12 @@ const transform_file_type = {
|
||||
|
||||
'application/vnd.ms-powerpoint': 'document/ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document/pptx',
|
||||
|
||||
'application/vnd.ms-excel': 'document/xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document/xlsx',
|
||||
'text/csv': 'document/csv',
|
||||
|
||||
'application/json': 'document/json'
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
@@ -87,6 +94,13 @@ const ALL_FILE_TYPE: {
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
csv: 'text/csv',
|
||||
|
||||
json: 'application/json',
|
||||
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
@@ -130,11 +144,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
onChange,
|
||||
disabled = false,
|
||||
fileSize = 5,
|
||||
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
|
||||
isAutoUpload = true,
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
requestConfig,
|
||||
featureConfig,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -142,18 +156,37 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
|
||||
const [accept, setAccept] = useState<string | undefined>();
|
||||
|
||||
const fileType = useMemo(() => {
|
||||
let types: string[] = [];
|
||||
['image', 'document', 'video', 'audio'].forEach(type => {
|
||||
if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
|
||||
types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
|
||||
}
|
||||
})
|
||||
|
||||
return types
|
||||
}, [featureConfig])
|
||||
|
||||
/**
|
||||
* Validates file type and size before upload
|
||||
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
|
||||
*/
|
||||
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
|
||||
// Validate file size
|
||||
if (fileSize) {
|
||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: fileSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Determine file category and get max size from featureConfig
|
||||
const mimePrefix = file.type?.split('/')[0]
|
||||
const categoryMap: Record<string, keyof FeaturesConfigForm['file_upload']> = {
|
||||
image: 'image_max_size_mb',
|
||||
video: 'video_max_size_mb',
|
||||
audio: 'audio_max_size_mb',
|
||||
}
|
||||
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
|
||||
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
const isLtMaxSize = fileSizeMB < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Validate file type
|
||||
if (fileType && fileType.length > 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 17:47:09
|
||||
* @Last Modified time: 2026-03-18 21:10:01
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
@@ -18,25 +18,28 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Input, Select, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding remote files via URL
|
||||
*/
|
||||
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
|
||||
refresh
|
||||
refresh,
|
||||
featureConfig
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -79,6 +82,20 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
handleOpen
|
||||
}));
|
||||
|
||||
const fileTypeOptions = useMemo(() => {
|
||||
const options = [];
|
||||
if (featureConfig?.image_enabled) {
|
||||
options.push({ label: t('memoryConversation.image'), value: 'image' });
|
||||
}
|
||||
if (featureConfig?.audio_enabled) {
|
||||
options.push({ label: t('memoryConversation.audio'), value: 'audio' });
|
||||
}
|
||||
if (featureConfig?.video_enabled) {
|
||||
options.push({ label: t('memoryConversation.video'), value: 'video' });
|
||||
}
|
||||
return options;
|
||||
}, [featureConfig, t])
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('memoryConversation.addRemoteFile')}
|
||||
@@ -98,26 +115,21 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' },
|
||||
{ label: t('memoryConversation.audio'), value: 'audio' },
|
||||
{ label: t('memoryConversation.video'), value: 'video' },
|
||||
]}
|
||||
className="rb:w-30"
|
||||
options={fileTypeOptions}
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
className="rb:mb-0!"
|
||||
className="rb:mb-0! rb:flex-1!"
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5!" />
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} />
|
||||
</FormItem>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 17:19:20
|
||||
* @Last Modified time: 2026-03-19 21:32:05
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,13 +14,12 @@ 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, Form, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { Flex, Skeleton, App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import type { HistoryItem } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import { randomString } from '@/utils/common'
|
||||
@@ -33,20 +32,14 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
*/
|
||||
const Conversation: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const { message: messageApi, modal } = App.useApp()
|
||||
const { token } = useParams()
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
@@ -62,35 +55,22 @@ const Conversation: FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [isHasMemory, setIsHasMemory] = useState(false)
|
||||
const [memory, setMemory] = useState(true)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
const [config, setConfig] = useState<Record<string, any>>({})
|
||||
|
||||
const [form] = Form.useForm<QueryParams>()
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
if (shareToken && shareToken !== '') return
|
||||
getShareToken(token as string, userId || randomString(12, false))
|
||||
.then(res => {
|
||||
const response = res as { access_token: string } || {}
|
||||
const response = res as { access_token: string } || {}
|
||||
localStorage.setItem(`shareToken_${token}`, response.access_token ?? '')
|
||||
setShareToken(response.access_token ?? '')
|
||||
})
|
||||
@@ -101,12 +81,16 @@ const Conversation: FC = () => {
|
||||
getHistory()
|
||||
}
|
||||
}, [token, shareToken, page, hasMore, historyList])
|
||||
|
||||
useEffect(() => {
|
||||
if (shareToken && token) {
|
||||
getExperienceConfig(token)
|
||||
.then(res => {
|
||||
const response = res as { variables: Variable[] }
|
||||
setVariables(response.variables || [])
|
||||
const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
|
||||
toolbarRef.current?.setVariables(response.variables || [])
|
||||
setConfig(response)
|
||||
setFeatures(response.features)
|
||||
setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
|
||||
})
|
||||
} else {
|
||||
setChatList([])
|
||||
@@ -117,7 +101,7 @@ const Conversation: FC = () => {
|
||||
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] = [];
|
||||
}
|
||||
@@ -128,9 +112,7 @@ const Conversation: FC = () => {
|
||||
|
||||
/** Fetch conversation history with pagination */
|
||||
const getHistory = (flag: boolean = false) => {
|
||||
if (!token || (pageLoading || !hasMore) && !flag) {
|
||||
return
|
||||
}
|
||||
if (!token || (pageLoading || !hasMore) && !flag) return
|
||||
setPageLoading(true);
|
||||
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
|
||||
.then(res => {
|
||||
@@ -153,19 +135,14 @@ const Conversation: FC = () => {
|
||||
setHasMore(response.page.hasnext);
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setPageLoading(false);
|
||||
})
|
||||
.finally(() => setPageLoading(false))
|
||||
}
|
||||
/** Switch to different conversation or start new one */
|
||||
const handleChangeHistory = (id: string | null) => {
|
||||
if (id !== conversation_id) {
|
||||
setConversationId(id)
|
||||
}
|
||||
if (!id) {
|
||||
setMessage('')
|
||||
}
|
||||
if (id !== conversation_id) setConversationId(id)
|
||||
if (!id) setMessage('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation_id) {
|
||||
getConversationDetail(token as string, conversation_id)
|
||||
@@ -178,43 +155,40 @@ const Conversation: FC = () => {
|
||||
}
|
||||
}, [conversation_id])
|
||||
|
||||
/** Add user message to chat */
|
||||
const addUserMessage = (message: string = '', files?: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
conversation_id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
/** Add empty assistant message placeholder */
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const newAssistantMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
created_at: Date.now(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
}
|
||||
setChatList(prev => [...prev, newAssistantMessage])
|
||||
content: ''
|
||||
}])
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content: string = '') => {
|
||||
if (!content) return
|
||||
if (streamLoading) {
|
||||
setStreamLoading(false)
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string = '', audio_url?: string) => {
|
||||
if (!content && !audio_url) return
|
||||
if (streamLoading) setStreamLoading(false)
|
||||
setChatList(prev => {
|
||||
const lastList = [...prev]
|
||||
const lastIndex = lastList.length - 1
|
||||
const lastMsg = lastList[lastIndex]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
return [
|
||||
...lastList.slice(0, lastList.length - 1),
|
||||
...lastList.slice(0, lastIndex),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + content,
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -222,22 +196,17 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
/** Send message and handle streaming response */
|
||||
const handleSend = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
// Validate required variables before sending
|
||||
if (!token || !shareToken) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
@@ -248,33 +217,34 @@ const Conversation: FC = () => {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
return
|
||||
}
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
|
||||
let currentConversationId: string | null = null
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach((item) => {
|
||||
switch(item.event) {
|
||||
const { content, conversation_id: curId, audio_url } = item.data as { content: string; conversation_id: string; audio_url?: string; }
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
case 'node_start':
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
currentConversationId = newId
|
||||
break
|
||||
case 'message':
|
||||
const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; }
|
||||
updateAssistantMessage(content)
|
||||
|
||||
if (curId) {
|
||||
currentConversationId = curId;
|
||||
}
|
||||
updateAssistantMessage(content, audio_url)
|
||||
if (curId) currentConversationId = curId;
|
||||
break
|
||||
case 'end':
|
||||
case 'workflow_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
setLoading(false)
|
||||
if (currentConversationId && currentConversationId !== conversation_id) {
|
||||
setConversationId(currentConversationId)
|
||||
@@ -285,9 +255,9 @@ const Conversation: FC = () => {
|
||||
})
|
||||
};
|
||||
|
||||
form.setFieldValue('files', [])
|
||||
sendConversation({
|
||||
...rest,
|
||||
web_search: webSearch,
|
||||
memory,
|
||||
message: message || '',
|
||||
stream: true,
|
||||
conversation_id: conversation_id || null,
|
||||
@@ -314,32 +284,19 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (fileList?: any[]) => {
|
||||
if (!fileList || fileList.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
|
||||
}
|
||||
const updateFileList = (fileList?: any[]) => {
|
||||
console.log('fileList', fileList)
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
const handleChangeMemory = (value: boolean) => {
|
||||
if (config.app_type === 'workflow') return;
|
||||
modal.confirm({
|
||||
title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
setMemory(value)
|
||||
},
|
||||
onCancel: () => {
|
||||
setMemory(!value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -354,8 +311,8 @@ const Conversation: FC = () => {
|
||||
className="rb:cursor-pointer rb:border rb:border-[#155EEF] rb:rounded-xl rb:p-3! rb:mx-4! rb:text-[16px] rb:font-medium rb:text-[#155EEF] rb:h-12! rb:mb-5!"
|
||||
onClick={() => handleChangeHistory(null)}
|
||||
>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 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')}
|
||||
</Flex>
|
||||
@@ -370,7 +327,6 @@ const Conversation: FC = () => {
|
||||
next={getHistory}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active />}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
scrollableTarget="scrollableDiv"
|
||||
>
|
||||
{Object.entries(groupHistoryList).map(([date, items]) => (
|
||||
@@ -379,16 +335,16 @@ const Conversation: FC = () => {
|
||||
|
||||
<Flex vertical gap={4}>
|
||||
{items.map(item => (
|
||||
<div key={item.updated_at} className="rb:mb-3">
|
||||
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 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 key={item.updated_at} className="rb:mb-3">
|
||||
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 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>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
@@ -399,108 +355,62 @@ const Conversation: FC = () => {
|
||||
|
||||
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
>
|
||||
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
onChange={fileChange}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!fileList.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
uploadAction={shareFileUploadUrlWithoutApiPrefix}
|
||||
uploadRequestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
extra={
|
||||
<>
|
||||
{features?.web_search?.enabled &&
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
checked={webSearch}
|
||||
onChange={setWebSearch}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
{t('memoryConversation.web_search')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
}
|
||||
{isHasMemory &&
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
checked={memory}
|
||||
disabled={config.app_type === 'workflow'}
|
||||
onChange={handleChangeMemory}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
{t('memoryConversation.memory')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
{variables.length > 0 && (
|
||||
<Form.Item name="variables" className="rb:mb-0!">
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Chat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
export default Conversation
|
||||
export default Conversation
|
||||
|
||||
@@ -83,6 +83,7 @@ const CreateDataset = () => {
|
||||
const [form] = Form.useForm<ContentFormData>();
|
||||
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
|
||||
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
|
||||
const [textFormValid, setTextFormValid] = useState<boolean>(false);
|
||||
|
||||
const [pollingLoading, setPollingLoading] = useState<boolean>(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -546,8 +547,7 @@ const CreateDataset = () => {
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
return (<>
|
||||
{contextHolder}
|
||||
|
||||
<div className='rb:p-3 rb:pt-2 rb:h-full rb:flex rb:flex-col'>
|
||||
@@ -610,58 +610,67 @@ const CreateDataset = () => {
|
||||
{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'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</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'>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
<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'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</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-20'>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={() => {
|
||||
// 检查表单字段是否都已填写
|
||||
const values = form.getFieldsValue();
|
||||
const isValid = !!(values.title?.trim() && values.content?.trim());
|
||||
setTextFormValid(isValid);
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <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>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <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:py-6 rb:px-40'>
|
||||
<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>
|
||||
@@ -676,7 +685,7 @@ const CreateDataset = () => {
|
||||
<div className='rb:mt-4'>
|
||||
<div
|
||||
className={`rb:flex rb:items-center rb:w-full rb:border rb:rounded-lg rb:p-4 rb:cursor-pointer ${
|
||||
pdfEnhancementEnabled ? 'rb:border-gray-900' : 'rb:border-gray-300'
|
||||
pdfEnhancementEnabled ? 'rb:border-blue-500' : 'rb:border-gray-300'
|
||||
}`}
|
||||
// onClick={() => setPdfEnhancementEnabled(!pdfEnhancementEnabled)}
|
||||
>
|
||||
@@ -836,8 +845,7 @@ const CreateDataset = () => {
|
||||
</div>
|
||||
// </Spin>
|
||||
)}
|
||||
|
||||
<div className={`rb:flex rb:p-6 rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-40 rb:mt-10' : ''}`}>
|
||||
<div className={`rb:flex rb:p-6 rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-28 rb:mt-10' : ''}`}>
|
||||
{current !== 0 && (
|
||||
<Button onClick={handlePrev} disabled={current === 0 || pollingLoading}>
|
||||
{t('common.previous') || 'Prev'}
|
||||
@@ -846,7 +854,11 @@ const CreateDataset = () => {
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={current === 2 ? handleStartUpload : handleNext}
|
||||
disabled={pollingLoading || (current === 0 && rechunkFileIds.length === 0)}
|
||||
disabled={
|
||||
pollingLoading ||
|
||||
(current === 0 && source === 'local' && rechunkFileIds.length === 0) ||
|
||||
(current === 0 && source === 'text' && !textFormValid)
|
||||
}
|
||||
>
|
||||
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
|
||||
</Button>
|
||||
|
||||
@@ -566,6 +566,7 @@ const Private: FC = () => {
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 160,
|
||||
render: (value: string | 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">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-03 14:46:08
|
||||
* @LastEditTime: 2026-03-09 16:39:07
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -58,16 +58,21 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspaceIds = spaceList
|
||||
.map(item => item.target_kb?.workspace_id)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
console.log('Workspace IDs:', workspaceIds);
|
||||
shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds);
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const handleChange = (checked: boolean, item: any) => {
|
||||
// Toggle shared knowledge base status
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-12-03 18:44:58
|
||||
* @LastEditTime: 2026-03-09 16:34:51
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -50,34 +50,38 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
setSpaceList(filteredItems as SpaceItem[]);
|
||||
}
|
||||
const handleShare = async() => {
|
||||
|
||||
// Get all data with checked = true
|
||||
const checkedItems = spaceList.filter(item => item.is_active);
|
||||
debugger
|
||||
// Get currently selected item (corresponding to curIndex)
|
||||
const selectedItem = curIndex !== -1 ? spaceList[curIndex] : null;
|
||||
if(!selectedItem){
|
||||
messageApi.error(t('knowledgeBase.selectSpace'));
|
||||
return;
|
||||
}
|
||||
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'));
|
||||
}
|
||||
// Call parent component's callback function with selected data
|
||||
onShare?.({
|
||||
checkedItems,
|
||||
selectedItem
|
||||
});
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
setLoading(true);
|
||||
try {
|
||||
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'));
|
||||
}
|
||||
// Call parent component's callback function with selected data
|
||||
onShare?.({
|
||||
checkedItems,
|
||||
selectedItem
|
||||
});
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const handleClick = (index: number, checked: boolean) => {
|
||||
if (!checked) return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:09:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 16:00:39
|
||||
* @Last Modified time: 2026-03-20 10:22:08
|
||||
*/
|
||||
/**
|
||||
* Memory Conversation Page
|
||||
@@ -83,7 +83,7 @@ export interface LogItem {
|
||||
type: string;
|
||||
title: string;
|
||||
data?: DataItem[] | AnyObject;
|
||||
raw_results?: string | AnyObject;
|
||||
raw_results?: string | Record<string, AnyObject>;
|
||||
summary?: string;
|
||||
query?: string;
|
||||
reason?: string;
|
||||
@@ -236,47 +236,50 @@ const MemoryConversation: FC = () => {
|
||||
handleExpand={() => setExpandedLogs(prev => ({ ...prev, [logIndex]: !prev[logIndex] }))}
|
||||
extra={log.type === 'verification' && <div className="rb-border rb:rounded-lg rb:py-1 rb:px-2 rb:text-[12px] rb:font-medium rb:leading-4.5 rb:text-[#FF5D34]">{log.result}</div>}
|
||||
>
|
||||
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{log.data.map(vo => (
|
||||
<ContentWrapper key={vo.id}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
|
||||
<div className="rb:mt-2 rb:text-[12px] rb:text-[#5B6167]">{vo.reason}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{Object.keys(log.data).map((key: string) => (
|
||||
<ContentWrapper key={key}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{key}</div>
|
||||
{(log.data as Record<string, string[]>)[key].map((item, index) => (
|
||||
<div key={index} className="rb:mt-2 rb:text-[#5B6167] rb:text-[12px]">{item}</div>
|
||||
))}
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{log.data.map(vo => (
|
||||
<ContentWrapper key={vo.id}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
|
||||
<div className="rb:mt-2 rb:text-[12px] rb:text-[#5B6167]">{vo.reason}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'search_result' && log.raw_results
|
||||
? <ContentWrapper>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
||||
<div className='rb:mt-2 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: { statement: string }, index: number) => (
|
||||
<div key={index}>{item.statement}</div>
|
||||
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{Object.keys(log.data).map((key: string) => (
|
||||
<ContentWrapper key={key}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{key}</div>
|
||||
{(log.data as Record<string, string[]>)[key].map((item, index) => (
|
||||
<div key={index} className="rb:mt-2 rb:text-[#5B6167] rb:text-[12px]">{item}</div>
|
||||
))}
|
||||
{log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item: { content: string }, index: number) => (
|
||||
<div key={index}>{item.content}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'search_result' && log.raw_results && typeof log.raw_results !== 'string'
|
||||
? <ContentWrapper>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
||||
{(log.raw_results.reranked_results as AnyObject)?.communities?.length > 0 && <>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[12px]">{t('memoryConversation.communities')}</div>
|
||||
<ul className='rb:mt-2 rb:text-[12px] rb:text-[#5B6167] rb:list-disc rb:pl-4'>
|
||||
{((log.raw_results.reranked_results as AnyObject)?.communities as { content: string }[]).map((item, index: number) => (
|
||||
<li key={index}>{item.content}</li>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</ul>
|
||||
</>}
|
||||
{(log.raw_results.reranked_results as AnyObject)?.summaries?.length > 0 && <>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[12px]">{t('memoryConversation.summaries')}</div>
|
||||
<ul className='rb:mt-2 rb:text-[12px] rb:text-[#5B6167] rb:list-disc rb:pl-4'>
|
||||
{((log.raw_results.reranked_results as AnyObject)?.summaries as { content: string }[]).map((item, index: number) => (
|
||||
<li key={index}>{item.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>}
|
||||
</ContentWrapper>
|
||||
: log.type === 'retrieval_summary' && log.summary
|
||||
? <ContentWrapper>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{log.summary}</div>
|
||||
@@ -310,9 +313,9 @@ const MemoryConversation: FC = () => {
|
||||
}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
: null
|
||||
}
|
||||
</ResultCard>
|
||||
: null
|
||||
}
|
||||
</ResultCard>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:28
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:31:43
|
||||
* @Last Modified time: 2026-03-11 15:08:24
|
||||
*/
|
||||
/**
|
||||
* Custom Model Modal
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App, Checkbox } from 'antd';
|
||||
import { Form, Input, App, Checkbox, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types';
|
||||
@@ -35,6 +35,7 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [form] = Form.useForm<CustomModelForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const modelType = Form.useWatch(['type'], form);
|
||||
const isOmni = Form.useWatch(['is_omni'], form);
|
||||
|
||||
@@ -46,6 +47,8 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
|
||||
/** Close modal and reset state */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ModelListItem);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
@@ -73,8 +76,10 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
/** Update or create custom model */
|
||||
const handleUpdate = (data: CustomModelForm) => {
|
||||
setLoading(true)
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
const { type, provider, ...rest} = data
|
||||
const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data)
|
||||
const res = isEdit ? updateCustomModel(model.id, rest, controller.signal) : addCustomModel(data, controller.signal)
|
||||
|
||||
res.then(() => {
|
||||
refresh?.(isEdit)
|
||||
@@ -124,15 +129,15 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
console.log('modelType', modelType)
|
||||
return (
|
||||
<RbModal
|
||||
title={isEdit ? `${model.name} - ${t('modelNew.modelConfiguration')}` : t('modelNew.createCustomModel')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t(`common.${isEdit ? 'save' : 'create'}`)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.${isEdit ? 'save' : 'create'}`)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:40
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:40
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:12:17
|
||||
*/
|
||||
/**
|
||||
* Key Configuration Modal
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App } from 'antd';
|
||||
import { Form, Input, App, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { KeyConfigModalForm, ProviderModelItem, KeyConfigModalRef, KeyConfigModalProps } from '../types';
|
||||
@@ -30,9 +30,12 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
const [model, setModel] = useState<ProviderModelItem>({} as ProviderModelItem);
|
||||
const [form] = Form.useForm<KeyConfigModalForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
|
||||
/** Close modal and reset state */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ProviderModelItem);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
@@ -51,10 +54,13 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
.then((values) => {
|
||||
setLoading(true)
|
||||
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
|
||||
updateProviderApiKeys({
|
||||
...values,
|
||||
provider: model.provider
|
||||
}).then((res) => {
|
||||
}, controller.signal).then((res) => {
|
||||
if (refresh) {
|
||||
refresh();
|
||||
}
|
||||
@@ -81,9 +87,10 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
title={`${model.provider} - ${t('modelNew.keyConfig')}`}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t(`common.save`)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.save`)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:11:06
|
||||
*/
|
||||
/**
|
||||
* Multi-Key Configuration Modal
|
||||
@@ -28,9 +28,12 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
const [model, setModel] = useState<ModelListItem>({} as ModelListItem);
|
||||
const [form] = Form.useForm<MultiKeyForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
|
||||
/** Close modal and refresh parent */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ModelListItem);
|
||||
refresh?.()
|
||||
|
||||
@@ -60,12 +63,14 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
setLoading(true)
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
addModelApiKey(model.id, {
|
||||
...values,
|
||||
model_config_id: model.id,
|
||||
model_name: model.name,
|
||||
provider: model.provider,
|
||||
}).then(() => {
|
||||
}, controller.signal).then(() => {
|
||||
message.success(t('common.saveSuccess'))
|
||||
form.resetFields();
|
||||
getData(model)
|
||||
@@ -98,7 +103,6 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
{model.api_keys && model.api_keys.length > 0 && (
|
||||
<div className="rb:mb-4">
|
||||
|
||||
@@ -1,131 +1,342 @@
|
||||
import React, { useState, useRef, type ReactNode } from 'react';
|
||||
import { Input, Button, Spin, App } from 'antd';
|
||||
import React, { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { Input, Button, App, Card, Space, Skeleton, Tag } from 'antd';
|
||||
import { SearchOutlined, SettingOutlined, GlobalOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal';
|
||||
|
||||
import McpServiceModal from './components/McpServiceModal';
|
||||
import type { McpServiceModalRef } from './types';
|
||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||
import Empty from '@/components/Empty/index'
|
||||
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
logo_url: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
description: string;
|
||||
api_key?: string;
|
||||
connected: boolean;
|
||||
mcpCount: number;
|
||||
mcp_count: number;
|
||||
created_at?: number;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
interface MarketMcp {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: string;
|
||||
desc: string;
|
||||
downloads?: string;
|
||||
stars?: string;
|
||||
icon: string;
|
||||
configTemplate: any;
|
||||
chinese_name?: string;
|
||||
description: string;
|
||||
logo_url: string;
|
||||
publisher: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
view_count?: number;
|
||||
activated?: boolean;
|
||||
inDatabase?: boolean;
|
||||
locales?: {
|
||||
[lang: string]: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MarketCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface MarketApiResponse {
|
||||
items: MarketSource[];
|
||||
}
|
||||
|
||||
const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const getLocaleField = (mcp: MarketMcp, field: 'name' | 'description') => {
|
||||
const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en';
|
||||
return mcp.locales?.[lang]?.[field] || mcp[field] || '';
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const marketConfigModalRef = useRef<MarketConfigModalRef>(null);
|
||||
const [marketSources, setMarketSources] = useState<MarketSource[]>([
|
||||
{ id: 'smithery', name: 'Smithery', category: 'official', icon: '🔧', url: 'https://mcp.smithery.ai', desc: '官方 MCP 服务市场,提供丰富的 MCP 服务', apiKey: '', connected: false, mcpCount: 2847 },
|
||||
{ id: 'mcpmarket', name: 'MCP Market', category: 'official', icon: '🏪', url: 'https://mcpmarket.com', desc: '综合性 MCP 市场平台', apiKey: '', connected: false, mcpCount: 1523 },
|
||||
{ id: 'glama', name: 'Glama.ai MCP', category: 'official', icon: '✨', url: 'https://glama.ai/mcp', desc: 'Glama AI 提供的 MCP 服务集合', apiKey: '', connected: false, mcpCount: 892 },
|
||||
{ id: 'github-mcp', name: 'modelcontextprotocol/servers', category: 'official', icon: '🐙', url: 'https://github.com/modelcontextprotocol/servers', desc: 'GitHub 官方 MCP 服务器仓库', apiKey: '', connected: true, mcpCount: 156 },
|
||||
{ id: 'aliyun-bailian', name: '阿里云百炼 MCP', category: 'china-cloud', icon: '☁️', url: 'https://bailian.console.aliyun.com/mcp', desc: '阿里云百炼平台 MCP 市场', apiKey: '', connected: false, mcpCount: 423 },
|
||||
{ id: 'modelscope', name: '魔搭社区 MCP', category: 'china-cloud', icon: '🎭', url: 'https://modelscope.cn/mcp', desc: '阿里达摩院魔搭社区 MCP 市场', apiKey: '', connected: false, mcpCount: 312 },
|
||||
]);
|
||||
|
||||
const [categories] = useState<MarketCategory[]>([
|
||||
{ id: 'official', name: '官方/综合', icon: '🌐' },
|
||||
{ id: 'china-cloud', name: '国内云', icon: '☁️' },
|
||||
{ id: 'community', name: '社区/垂直', icon: '👥' }
|
||||
]);
|
||||
|
||||
const [mcpCache, setMcpCache] = useState<Record<string, MarketMcp[]>>({
|
||||
'github-mcp': [
|
||||
{ id: 'gh-1', name: 'Fetch', provider: 'modelcontextprotocol', type: 'Hosted', desc: '使用浏览器模拟大型语言模型检索和处理网页内容', downloads: '203.7m', stars: '308.2k', icon: '🌐', configTemplate: {} },
|
||||
{ id: 'gh-2', name: 'Filesystem', provider: 'modelcontextprotocol', type: 'Local', desc: '安全的文件系统操作,支持读写文件和目录管理', downloads: '156.2m', stars: '245.1k', icon: '📁', configTemplate: {} },
|
||||
{ id: 'gh-3', name: 'GitHub', provider: 'modelcontextprotocol', type: 'Hosted', desc: 'GitHub API 集成,支持仓库、Issue、PR 等操作', downloads: '89.4m', stars: '178.3k', icon: '🐙', configTemplate: {} },
|
||||
]
|
||||
});
|
||||
|
||||
const mcpServiceModalRef = useRef<McpServiceModalRef>(null);
|
||||
const [marketSources, setMarketSources] = useState<MarketSource[]>([]);
|
||||
const [categories, setCategories] = useState<MarketCategory[]>([]);
|
||||
const [mcpCache, setMcpCache] = useState<Record<string, MarketMcp[]>>({});
|
||||
const [mcpTotal, setMcpTotal] = useState(0);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [configIdMap, setConfigIdMap] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const searchTimerRef = useRef<number | null>(null);
|
||||
|
||||
const handleSelectSource = (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
};
|
||||
|
||||
const handleRefresh = (sourceId: string) => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
// 模拟刷新数据
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
message.success(`${source.name} 列表已刷新`);
|
||||
// 获取市场数据
|
||||
useEffect(() => {
|
||||
const fetchMarketData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getMarketTools({}) as MarketApiResponse;
|
||||
if (response?.items && Array.isArray(response.items)) {
|
||||
setMarketSources(response.items);
|
||||
|
||||
// 根据 category 字段分组
|
||||
const categoryMap = new Map<string, MarketCategory>();
|
||||
response.items.forEach(item => {
|
||||
if (item.category && !categoryMap.has(item.category)) {
|
||||
categoryMap.set(item.category, {
|
||||
id: item.category,
|
||||
name: item.category
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setCategories(Array.from(categoryMap.values()));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取市场数据失败:', error);
|
||||
message.error('获取市场数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMarketData();
|
||||
}, [message]);
|
||||
|
||||
const fetchMcpList = async (sourceId: string, page = 1, append = false, keywords = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let configId = configIdMap[sourceId];
|
||||
|
||||
// 如果没有缓存 configId,先获取配置
|
||||
if (!configId) {
|
||||
const config: any = await getMarketConfig(sourceId);
|
||||
if (config?.id) {
|
||||
configId = config.id;
|
||||
setConfigIdMap(prev => ({ ...prev, [sourceId]: configId }));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一次加载时获取已激活列表
|
||||
let activatedIds: string[] = activatedMcps;
|
||||
if (page === 1 && !append) {
|
||||
const activatedRes: any = await getMarketMCPsActivated({ mcp_market_config_id: configId });
|
||||
if (activatedRes && Array.isArray(activatedRes)) {
|
||||
activatedIds = activatedRes.map((item: any) => item.id);
|
||||
setActivatedMcps(activatedIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全量工具列表,用于标记已入库的 MCP
|
||||
const allTools: any = await getTools({ tool_type: 'mcp' });
|
||||
const toolsList = Array.isArray(allTools) ? allTools : [];
|
||||
|
||||
const res: any = await getMarketMCPs({
|
||||
mcp_market_config_id: configId,
|
||||
page,
|
||||
pagesize: pageSize,
|
||||
...(keywords ? { keywords } : {})
|
||||
});
|
||||
if (res?.items && Array.isArray(res.items)) {
|
||||
// 标记已激活和已入库的 MCP
|
||||
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
||||
// 检查是否已入库:market_id = sourceId, market_config_id = configId, mcp_service_id = item.id
|
||||
const isInDatabase = toolsList.some((tool: any) =>
|
||||
tool.config_data?.market_id === sourceId &&
|
||||
tool.config_data?.market_config_id === configId &&
|
||||
tool.config_data?.mcp_service_id === item.id
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
activated: activatedIds.includes(item.id),
|
||||
inDatabase: isInDatabase
|
||||
};
|
||||
});
|
||||
|
||||
setMcpCache(prev => ({
|
||||
...prev,
|
||||
[sourceId]: append ? [...(prev[sourceId] || []), ...mcpsWithActivated] : mcpsWithActivated
|
||||
}));
|
||||
}
|
||||
if (res?.page) {
|
||||
setMcpTotal(res.page.total || 0);
|
||||
setHasMore(!!res.page.has_next);
|
||||
setCurrentPage(res.page.page || page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = (sourceId: string) => {
|
||||
const loadMore = useCallback(() => {
|
||||
if (!selectedSource || loading) return;
|
||||
fetchMcpList(selectedSource, currentPage + 1, true, searchKeyword);
|
||||
}, [selectedSource, currentPage, loading, searchKeyword]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
|
||||
// 如果清空搜索框,恢复原始列表
|
||||
if (!value.trim()) {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新加载原始列表
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新搜索
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, value);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSelectSource = async (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
setMcpTotal(0);
|
||||
|
||||
// 如果缓存中已有数据,直接使用
|
||||
if (mcpCache[sourceId]) return;
|
||||
|
||||
await fetchMcpList(sourceId, 1);
|
||||
};
|
||||
|
||||
const handleRefresh = async (sourceId: string) => {
|
||||
// 清除缓存,重新从第一页加载
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[sourceId];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
await fetchMcpList(sourceId, 1);
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
message.success(`${source.name} ${t('tool.marketRefreshSuccess')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = async (sourceId: string) => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (!source) return;
|
||||
try {
|
||||
const config: any = await getMarketConfig(sourceId);
|
||||
console.log('获取到的配置数据:', config);
|
||||
marketConfigModalRef.current?.handleOpen({
|
||||
...source,
|
||||
connected: config?.status === 1,
|
||||
token: config?.token || '',
|
||||
configId: config?.id || '',
|
||||
});
|
||||
} catch {
|
||||
marketConfigModalRef.current?.handleOpen(source);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (sourceId: string, apiKey: string) => {
|
||||
// 更新市场源状态
|
||||
const handleOpenMcpServiceModal = async (mcp: MarketMcp) => {
|
||||
if (!selectedSource || !configIdMap[selectedSource]) return;
|
||||
try {
|
||||
const detail: any = await getMarketMCPDetail({
|
||||
mcp_market_config_id: configIdMap[selectedSource],
|
||||
server_id: mcp.id,
|
||||
});
|
||||
const source = marketSources.find(s => s.id === selectedSource);
|
||||
const toolItem = {
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
source_channel: source?.name || '',
|
||||
market_id: selectedSource,
|
||||
market_config_id: configIdMap[selectedSource],
|
||||
mcp_service_id: mcp.id,
|
||||
config_data: {
|
||||
server_url: detail.servers?.[0]?.url || '',
|
||||
connection_config: {
|
||||
auth_type: 'none',
|
||||
timeout: 30,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
mcpServiceModalRef.current?.handleOpen(toolItem as any);
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 服务详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (sourceId: string, configId: string) => {
|
||||
// 更新市场源状态,缓存 configId
|
||||
setMarketSources(prev => prev.map(source => {
|
||||
if (source.id === sourceId) {
|
||||
return {
|
||||
...source,
|
||||
apiKey,
|
||||
connected: true
|
||||
};
|
||||
return { ...source, connected: true };
|
||||
}
|
||||
return source;
|
||||
}));
|
||||
setConfigIdMap(prev => ({ ...prev, [sourceId]: configId }));
|
||||
|
||||
// 模拟获取MCP列表
|
||||
setTimeout(() => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source && !mcpCache[sourceId]) {
|
||||
// 生成模拟数据
|
||||
const mockData: MarketMcp[] = [
|
||||
{ id: `${sourceId}-1`, name: `${source.name} 服务 1`, provider: source.name, type: 'Hosted', desc: `来自 ${source.name} 的 MCP 服务`, downloads: '10.2m', stars: '23.4k', icon: '🔧', configTemplate: {} },
|
||||
{ id: `${sourceId}-2`, name: `${source.name} 服务 2`, provider: source.name, type: 'Local', desc: `来自 ${source.name} 的本地 MCP 服务`, downloads: '8.5m', stars: '18.7k', icon: '⚙️', configTemplate: {} }
|
||||
];
|
||||
setMcpCache(prev => ({
|
||||
...prev,
|
||||
[sourceId]: mockData
|
||||
}));
|
||||
}
|
||||
message.success(`已连接 ${source?.name}`);
|
||||
}, 800);
|
||||
// 使用 fetchMcpList 获取完整的 MCP 列表(包含激活状态和入库状态)
|
||||
await fetchMcpList(sourceId, 1);
|
||||
};
|
||||
|
||||
const handleRefreshAfterAdd = async () => {
|
||||
// 添加成功后,刷新当前选中的市场源的 MCP 列表
|
||||
if (!selectedSource) return;
|
||||
|
||||
// 清除缓存并重新加载,这样会重新获取工具列表并更新 inDatabase 标记
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
await fetchMcpList(selectedSource, 1);
|
||||
};
|
||||
|
||||
const renderSourceDetail = () => {
|
||||
if (!selectedSource) {
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:h-full rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">🏪</div>
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:text-gray-900 rb:mb-2">选择一个 MCP 市场</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:max-w-md">从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务</p>
|
||||
<Empty
|
||||
url={pageEmptyIcon}
|
||||
title={t('tool.marketSelectTitle')}
|
||||
subTitle={t('tool.marketSelectDesc')}
|
||||
size={200}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,170 +345,228 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
if (!source) return null;
|
||||
|
||||
const mcpList = mcpCache[selectedSource] || [];
|
||||
const filteredList = mcpList.filter(mcp =>
|
||||
mcp.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
mcp.desc.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rb:flex rb:justify-between rb:items-start rb:pb-6 rb:border-b rb:border-gray-200 rb:mb-6">
|
||||
<div className="rb:flex rb:gap-4">
|
||||
<div className="rb:text-5xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-xl rb:flex-shrink-0">
|
||||
{source.icon}
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:pb-0">
|
||||
<div className="rb:flex rb:items-center rb:gap-4">
|
||||
<div className="rb:w-10 rb:h-10 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-xl rb:flex-shrink-0 rb:overflow-hidden">
|
||||
{source.logo_url ? (
|
||||
<img
|
||||
src={source.logo_url}
|
||||
alt={source.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '48px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-5xl">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h2 className="rb:text-xl rb:font-semibold rb:text-gray-900 rb:mb-2">{source.name}</h2>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{source.desc}</p>
|
||||
<div className="rb:flex rb:items-center rb:flex-1">
|
||||
<h2 className="rb:text-xl rb:font-semibold rb:text-gray-900 rb:mb-2 rb:mr-2">{source.name}</h2>
|
||||
可用 MCP 服务 <span className="rb:text-gray-600 rb:font-normal">({mcpTotal})</span>
|
||||
{/* <p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{source.description}</p> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:flex rb:gap-3">
|
||||
<div className="rb:flex rb:gap-3 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
{t('tool.marketRefresh')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('tool.marketSearchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置
|
||||
{t('tool.marketConfigBtn')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||
前往市场
|
||||
{t('tool.marketVisit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:mt-6">
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-5">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:m-0">
|
||||
可用 MCP 服务 <span className="rb:text-gray-600 rb:font-normal">({mcpList.length})</span>
|
||||
</h3>
|
||||
<div className="rb:flex rb:gap-3 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
{mcpList.length > 0 && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索服务..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
||||
{!loading && mcpList.length === 0 ? (
|
||||
<Empty
|
||||
url={pageEmptyIcon}
|
||||
title={searchKeyword ? t('tool.marketNoSearchResult') : t('tool.marketNoData')}
|
||||
subTitle={searchKeyword ? t('tool.marketNoSearchResultDesc') : t('tool.marketNoDataDesc')}
|
||||
size={200}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpList.length > 0 ? (
|
||||
<Spin spinning={loading}>
|
||||
<div className="rb:grid rb:grid-cols-1 md:rb:grid-cols-2 lg:rb:grid-cols-3 rb:gap-4">
|
||||
{filteredList.map(mcp => (
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
dataLength={mcpList.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={null}
|
||||
scrollableTarget="mcpScrollableDiv"
|
||||
>
|
||||
<div
|
||||
className="rb:gap-4"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||
}}
|
||||
>
|
||||
{mcpList.map(mcp => (
|
||||
<div
|
||||
key={mcp.id}
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:pb-2 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
>
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-3">
|
||||
<div className="rb:text-3xl rb:w-12 rb:h-12 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-lg">
|
||||
{mcp.icon}
|
||||
<div className="rb:w-12 rb:h-12 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-lg rb:overflow-hidden">
|
||||
{mcp.logo_url ? (
|
||||
<img
|
||||
src={mcp.logo_url}
|
||||
alt={getLocaleField(mcp, 'name')}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🔧';
|
||||
parent.style.fontSize = '24px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-3xl">🔧</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`rb:px-2 rb:py-1 rb:rounded rb:text-xs rb:font-medium ${
|
||||
mcp.type === 'Hosted'
|
||||
? 'rb:bg-blue-50 rb:text-blue-700'
|
||||
: 'rb:bg-gray-100 rb:text-gray-600'
|
||||
}`}>
|
||||
{mcp.type}
|
||||
</span>
|
||||
{mcp.categories?.[0] && (
|
||||
<span className="rb:px-2 rb:py-1 rb:rounded rb:text-xs rb:font-medium rb:bg-blue-50 rb:text-blue-700">
|
||||
{mcp.categories[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-1">{mcp.name}</h3>
|
||||
{mcp.provider && (
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-1">{getLocaleField(mcp, 'name')}</h3>
|
||||
{mcp.publisher && (
|
||||
<div className="rb:mb-2">
|
||||
<span className="rb:text-xs rb:text-gray-500">@ {mcp.provider}</span>
|
||||
<span className="rb:text-xs rb:text-gray-500">{mcp.publisher.startsWith('@') ? mcp.publisher : `@${mcp.publisher}`}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed rb:mb-3 rb:min-h-[42px]">{mcp.desc}</p>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:line-clamp-2 rb:mb-3 rb:min-h-10">{getLocaleField(mcp, 'description')}</p>
|
||||
<div className="rb:flex rb:gap-4 rb:mb-3 rb:pt-3 rb:border-t rb:border-gray-100">
|
||||
{mcp.downloads && (
|
||||
{mcp.view_count != null && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
<GlobalOutlined /> {mcp.downloads}
|
||||
</span>
|
||||
)}
|
||||
{mcp.stars && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
⭐ {mcp.stars}
|
||||
<GlobalOutlined /> {mcp.view_count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-end">
|
||||
<Button type="primary" size="small">
|
||||
+ 添加
|
||||
<div className={`rb:flex rb:items-center ${mcp.activated || mcp.inDatabase ? 'rb:justify-between' : 'rb:justify-end'}`}>
|
||||
<div className="rb:flex rb:gap-2">
|
||||
{mcp.activated && <Tag color="success">{t('tool.marketActivated')}</Tag>}
|
||||
{mcp.inDatabase && <Tag color="blue">{t('tool.marketInDatabase')}</Tag>}
|
||||
</div>
|
||||
<Button disabled={mcp.inDatabase} type="primary" size="small" onClick={() => handleOpenMcpServiceModal(mcp)}>
|
||||
+ {t('tool.marketAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
) : (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:py-16 rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">{source.connected ? '📭' : '🔌'}</div>
|
||||
<h4 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-2">
|
||||
{source.connected ? '暂无可用的 MCP 服务' : '尚未连接此市场'}
|
||||
</h4>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-4">
|
||||
{source.connected ? '该市场暂时没有可用的服务' : '点击右上角"配置"按钮设置连接信息'}
|
||||
</p>
|
||||
{!source.connected && (
|
||||
<Button type="primary" onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置连接
|
||||
</Button>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:gap-4 rb:h-[calc(100vh-178px)]">
|
||||
<div className="rb:flex rb:gap-4 rb:h-[calc(100vh-138px)]">
|
||||
{/* 左侧市场源列表 */}
|
||||
<div className="rb:w-70 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-y-auto rb:flex-shrink-0">
|
||||
<div className="rb:p-4 rb:border-b rb:border-gray-200">
|
||||
<span className="rb:text-base rb:font-semibold rb:text-gray-900">MCP 市场</span>
|
||||
</div>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="rb:py-3 rb:border-b rb:border-gray-100 last:rb:border-b-0">
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-2 rb:text-xs rb:font-medium rb:text-gray-500 rb:uppercase">
|
||||
<span className="rb:text-sm">{cat.icon}</span>
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
<div className="rb:px-2 rb:py-1">
|
||||
{marketSources
|
||||
.filter(s => s.category === cat.id)
|
||||
.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`rb:flex rb:items-center rb:gap-2 rb:px-3 rb:py-2.5 rb:rounded-md rb:cursor-pointer rb:transition-all rb:relative ${
|
||||
selectedSource === source.id
|
||||
? 'rb:bg-blue-50 rb:text-blue-600'
|
||||
: 'hover:rb:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectSource(source.id)}
|
||||
>
|
||||
<span className="rb:text-lg rb:flex-shrink-0">{source.icon}</span>
|
||||
<span className="rb:flex-1 rb:text-sm rb:font-medium rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full">
|
||||
{source.mcpCount}
|
||||
</span>
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:ml-1">●</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rb:w-80 rb:h-full rb:overflow-y-auto">
|
||||
<Space size={12} direction="vertical" className="rb:w-full">
|
||||
{categories.map(cat => (
|
||||
<Card
|
||||
key={cat.id}
|
||||
type="inner"
|
||||
title={
|
||||
<div className="rb:flex rb:items-center rb:gap-2">
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
}
|
||||
classNames={{
|
||||
body: "rb:p-[10px]!",
|
||||
header: "rb:bg-[#F6F8FC]!"
|
||||
}}
|
||||
>
|
||||
<Space size={8} direction="vertical" className="rb:w-full">
|
||||
{marketSources
|
||||
.filter(s => s.category === cat.id)
|
||||
.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`rb:bg-white rb:rounded-lg rb:p-2 rb:border rb:cursor-pointer rb:flex rb:items-center rb:gap-2 rb:transition-all ${
|
||||
selectedSource === source.id
|
||||
? 'rb:border-[#155EEF] rb:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]'
|
||||
: 'rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]'
|
||||
}`}
|
||||
onClick={() => handleSelectSource(source.id)}
|
||||
>
|
||||
<div className="rb:w-5 rb:h-5 rb:flex-shrink-0 rb:flex rb:items-center rb:justify-center rb:overflow-hidden rb:rounded rb:bg-gray-100">
|
||||
{source.logo_url ? (
|
||||
<img
|
||||
src={source.logo_url}
|
||||
alt={source.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '16px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-base">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
{/* <span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
||||
{source.mcp_count}
|
||||
</span> */}
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<div className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-hidden">
|
||||
<div className="rb:flex-1 rb:border-l rb:border-gray-200 rb:overflow-hidden">
|
||||
<div className="rb:h-full rb:overflow-y-auto rb:p-6">
|
||||
{renderSourceDetail()}
|
||||
</div>
|
||||
@@ -308,6 +577,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
ref={marketConfigModalRef}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
<McpServiceModal
|
||||
ref={mcpServiceModalRef}
|
||||
refresh={handleRefreshAfterAdd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,6 @@ const Mcp: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getSta
|
||||
getData()
|
||||
})
|
||||
};
|
||||
|
||||
// 删除服务
|
||||
const handleDeleteService = (item: ToolItem) => {
|
||||
if (!item.id) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Button, App, Space } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { createMarketConfig,updateMarketConfig } from '@/api/tools';
|
||||
import RbModal from '@/components/RbModal';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
@@ -9,15 +10,16 @@ const FormItem = Form.Item;
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
logo_url: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
description: string;
|
||||
token?: string;
|
||||
connected: boolean;
|
||||
configId?: string;
|
||||
}
|
||||
|
||||
interface MarketConfigModalProps {
|
||||
onConnect: (sourceId: string, apiKey: string) => void;
|
||||
onConnect: (sourceId: string, configId: string) => void;
|
||||
}
|
||||
|
||||
export interface MarketConfigModalRef {
|
||||
@@ -35,6 +37,8 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSource, setCurrentSource] = useState<MarketSource | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState<{ token: string }>({ token: '' });
|
||||
const formValues = Form.useWatch([], form);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
@@ -42,32 +46,62 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
setLoading(false);
|
||||
setCurrentSource(null);
|
||||
setShowApiKey(false);
|
||||
setInitialValues({ token: '' });
|
||||
};
|
||||
|
||||
const handleOpen = (source: MarketSource) => {
|
||||
console.log('Modal 接收到的数据:', source);
|
||||
setCurrentSource(source);
|
||||
form.setFieldsValue({
|
||||
url: source.url,
|
||||
apiKey: source.apiKey,
|
||||
});
|
||||
setInitialValues({ token: source.token || '' });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleAfterOpenChange = (open: boolean) => {
|
||||
if (open && currentSource) {
|
||||
// Modal 完全打开后再设置表单值,使用 setTimeout 确保在下一个事件循环
|
||||
setTimeout(() => {
|
||||
form.setFieldsValue({
|
||||
token: currentSource.token || '',
|
||||
});
|
||||
console.log('Modal 打开后设置表单值:', { token: currentSource.token || '' });
|
||||
console.log('当前表单所有值:', form.getFieldsValue());
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
.then(async (values) => {
|
||||
if (!currentSource) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
onConnect(currentSource.id, values.apiKey || '');
|
||||
message.success(`正在连接 ${currentSource.name}...`);
|
||||
setLoading(false);
|
||||
try {
|
||||
let res: any;
|
||||
if (currentSource.configId) {
|
||||
// 更新配置
|
||||
res = await updateMarketConfig({
|
||||
mcp_market_config_id: currentSource.configId,
|
||||
token: values.token || '',
|
||||
status: 1,
|
||||
});
|
||||
message.success(t('tool.marketConfigUpdated', { name: currentSource.name }));
|
||||
} else {
|
||||
// 创建配置
|
||||
res = await createMarketConfig({
|
||||
mcp_market_id: currentSource.id || '',
|
||||
token: values.token || '',
|
||||
status: 1,
|
||||
});
|
||||
message.success(t('tool.marketConnecting', { name: currentSource.name }));
|
||||
}
|
||||
onConnect(currentSource.id, res.id || currentSource.configId);
|
||||
handleClose();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('表单验证失败:', err);
|
||||
@@ -82,6 +116,9 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否可以保存:token 字段必须有值
|
||||
const canSave = formValues?.token?.trim().length > 0;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
@@ -91,77 +128,97 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={`配置 ${currentSource.name}`}
|
||||
title={t('tool.marketConfig', { name: currentSource.name })}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText="保存并连接"
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
okText={t('tool.marketSaveAndConnect')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ disabled: !canSave }}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
{/* 市场源信息头部 */}
|
||||
<div className="rb:flex rb:gap-4 rb:mb-6 rb:p-4 rb:bg-gray-50 rb:rounded-lg">
|
||||
<div className="rb:text-4xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-white rb:rounded-lg rb:flex-shrink-0">
|
||||
{currentSource.icon}
|
||||
<div className="rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-white rb:rounded-lg rb:flex-shrink-0 rb:overflow-hidden">
|
||||
{currentSource.logo_url ? (
|
||||
<img
|
||||
src={currentSource.logo_url}
|
||||
alt={currentSource.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '32px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-4xl">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:mb-1 rb:text-gray-900">{currentSource.name}</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{currentSource.desc}</p>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{currentSource.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
key={currentSource?.id || 'new'}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{/* 市场地址 */}
|
||||
<FormItem
|
||||
name="url"
|
||||
label="市场地址"
|
||||
>
|
||||
<FormItem label={t('tool.marketUrl')}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="市场地址"
|
||||
value={currentSource.url}
|
||||
/>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
复制
|
||||
{t('tool.marketCopy')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
|
||||
{/* API Key */}
|
||||
<FormItem
|
||||
name="apiKey"
|
||||
name="token"
|
||||
label={
|
||||
<span>
|
||||
API Key <span className="rb:text-gray-400 rb:font-normal">(可选)</span>
|
||||
API Key
|
||||
</span>
|
||||
}
|
||||
extra="部分市场需要 API Key 才能获取完整的服务列表"
|
||||
rules={[
|
||||
{ required: true, message: t('tool.marketApiKeyRequired') },
|
||||
{ whitespace: true, message: t('tool.marketApiKeyRequired') }
|
||||
]}
|
||||
extra={<span style={{ display: 'inline-block', marginTop: 8 }}>{t('tool.marketApiKeyExtra')}</span>}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="输入 API Key 以获取更多服务"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={t('tool.marketApiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
suffix={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:p-3 rb:bg-gray-50 rb:rounded rb:text-sm">
|
||||
<span className="rb:text-gray-600">连接状态:</span>
|
||||
<span className="rb:text-gray-600">{t('tool.marketConnectionStatus')}:</span>
|
||||
<span className={`rb:font-medium ${currentSource.connected ? 'rb:text-green-600' : 'rb:text-gray-400'}`}>
|
||||
{currentSource.connected ? '● 已连接' : '○ 未连接'}
|
||||
{currentSource.connected ? t('tool.marketConnected') : t('tool.marketDisconnected')}
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -41,6 +41,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
const values = Form.useWatch<MCPToolItem>([], form)
|
||||
const requestHeaderModalRef = useRef<RequestHeaderModalRef>(null)
|
||||
const [requestHeaderList, setRequestHeaderList] = useState<RequestHeader[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
@@ -54,6 +55,12 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
// 如果有正在进行的请求,取消它
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false);
|
||||
@@ -70,7 +77,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
config: { ...config_data }
|
||||
})
|
||||
|
||||
if (config_data.connection_config.headers) {
|
||||
if (config_data?.connection_config?.headers) {
|
||||
console.log(Object.keys(config_data.connection_config.headers).map(key => ({
|
||||
key,
|
||||
value: config_data.connection_config.headers[key]
|
||||
@@ -81,6 +88,16 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
})))
|
||||
}
|
||||
setEditVo(data)
|
||||
} else if (data) {
|
||||
const { config_data, name, description, icon } = data
|
||||
form.setFieldsValue({
|
||||
name, description, icon,
|
||||
...(config_data ? { config: { ...config_data } } : {})
|
||||
})
|
||||
// 如果是从 Market 组件传来的数据(包含 market_id),保存完整的 data 用于后续提交
|
||||
if ((data as any).market_id) {
|
||||
setEditVo(data)
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
@@ -93,6 +110,10 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
setLoading(true);
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 创建新服务对象
|
||||
const { config, ...rest } = values
|
||||
|
||||
@@ -110,17 +131,42 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
}
|
||||
}
|
||||
}
|
||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||
|
||||
// 如果是从 Market 组件传来的数据,添加市场相关字段
|
||||
if ((editVo as any)?.market_id) {
|
||||
(newService.config as any).source_channel = (editVo as any).source_channel;
|
||||
(newService.config as any).market_id = (editVo as any).market_id;
|
||||
(newService.config as any).market_config_id = (editVo as any).market_config_id;
|
||||
(newService.config as any).mcp_service_id = (editVo as any).mcp_service_id;
|
||||
}
|
||||
|
||||
const request = editVo?.id
|
||||
? updateTool(editVo.id, newService, { signal: abortControllerRef.current.signal })
|
||||
: addTool(newService, { signal: abortControllerRef.current.signal })
|
||||
request.then((res: any) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
message.success(t('common.saveSuccess'));
|
||||
testConnection(res.tool_id || editVo?.id)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh()
|
||||
})
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh();
|
||||
|
||||
// 在后台测试连接,不阻塞用户操作
|
||||
testConnection(res.tool_id || editVo?.id).catch((err) => {
|
||||
console.error('测试连接失败:', err);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
// 如果是用户主动取消,不显示错误提示
|
||||
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||
console.log('请求已取消');
|
||||
} else {
|
||||
message.error(t('common.saveFailed'));
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
})
|
||||
@@ -150,7 +196,13 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
onCancel={handleClose}
|
||||
okText={t('tool.saveAndTest')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ loading: loading }}
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-01-05 17:22:23
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-06 15:08:38
|
||||
* @LastEditTime: 2026-03-06 15:11:31
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
@@ -16,7 +16,7 @@ import Custom from './Custom';
|
||||
import Market from './Market';
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
const tabKeys = ['mcp', 'inner', 'custom'] // , 'market'
|
||||
const tabKeys = ['mcp', 'inner', 'custom', 'market'] //
|
||||
const ToolManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('mcp');
|
||||
@@ -54,7 +54,7 @@ const ToolManagement: React.FC = () => {
|
||||
{activeTab === 'mcp' && <Mcp getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'inner' && <Inner getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'custom' && <Custom getStatusTag={getStatusTag} />}
|
||||
{/* {activeTab === 'market' && <Market getStatusTag={getStatusTag} />} */}
|
||||
{activeTab === 'market' && <Market getStatusTag={getStatusTag} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface ToolItem {
|
||||
tool_class: string;
|
||||
|
||||
schema_content: string;
|
||||
source_channel?: string;
|
||||
market_id?: string;
|
||||
market_config_id?: string;
|
||||
mcp_service_id?: string;
|
||||
};
|
||||
status: 'available' | 'unavailable';
|
||||
tags: string[];
|
||||
@@ -136,4 +140,11 @@ export interface ExecuteData {
|
||||
export interface CustomToolModalRef {
|
||||
handleOpen: (data?: ToolItem) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
export interface MarketQuery {
|
||||
mcp_market_config_id?: string;
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
keywords?: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 11:38:17
|
||||
* @Last Modified time: 2026-03-20 11:27:53
|
||||
*/
|
||||
/**
|
||||
* RAG User Memory Detail View
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Row, Col, Skeleton, Flex } from 'antd'
|
||||
import { Row, Col, Skeleton, Spin, Flex, Tooltip } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import aboutUs from '@/assets/images/userMemory/aboutUs.svg'
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getChunkSummaryTag,
|
||||
getUserProfile,
|
||||
getChunkInsight,
|
||||
generateRagProfile
|
||||
} from '@/api/memory'
|
||||
import Empty from '@/components/Empty'
|
||||
import ConversationMemory from './components/ConversationMemory'
|
||||
@@ -96,6 +98,20 @@ const Rag: FC = () => {
|
||||
})
|
||||
}
|
||||
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false)
|
||||
const handleRefresh = () => {
|
||||
if (refreshLoading || !id) return
|
||||
setRefreshLoading(true)
|
||||
generateRagProfile(id as string)
|
||||
.then(() => {
|
||||
getSummary()
|
||||
getInsightReport()
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
@@ -104,9 +120,22 @@ const Rag: FC = () => {
|
||||
>
|
||||
<Flex align="center" gap={12} className="rb:mb-6!">
|
||||
<div className="rb:size-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-xl rb:text-white rb:bg-[#155EEF]">{name?.[0]}</div>
|
||||
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
|
||||
{name}
|
||||
</div>
|
||||
<Flex justify="space-between">
|
||||
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
|
||||
{name}
|
||||
</div>
|
||||
<Tooltip title={t('common.refresh')}>
|
||||
{refreshLoading
|
||||
? <Spin indicator={<LoadingOutlined spin />} />
|
||||
: (
|
||||
<div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh.svg')]"
|
||||
onClick={handleRefresh}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* About Me */}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useState, type FC, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Spin, Flex } from 'antd';
|
||||
|
||||
import type { CommunityD3Node, CommunityGraphData, RawCommunityGraphData, RawCommunityNode } from '@/components/D3Graph/types'
|
||||
import { buildCommunityGraphData } from '@/components/D3Graph/utils'
|
||||
import CommunityGraph from '@/components/D3Graph/CommunityGraph'
|
||||
import { getMemoryCommunityGraph } from '@/api/memory'
|
||||
|
||||
// ─── Tooltip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NodeTooltip: FC<{ node: CommunityD3Node }> = ({ node }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #DFE4ED', borderRadius: 8,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: '10px 14px',
|
||||
minWidth: 180, maxWidth: 260, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: '#1a1a1a', fontSize: 14 }}>
|
||||
{node.properties?.name ?? node.name}
|
||||
</div>
|
||||
{node.properties?.description && (
|
||||
<div style={{ color: '#5B6167', lineHeight: '20px', marginBottom: 4 }}>
|
||||
{node.properties.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.type')}:
|
||||
<span style={{ color: '#1a1a1a' }}>{t(`userMemory.${node.properties?.entity_type}`)}</span>
|
||||
</div>
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.community')}:
|
||||
<span style={{ color: node.color, fontWeight: 500 }}>{node.properties?.community_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CommunityNetwork: FC<{ onSelectCommunity?: (node: RawCommunityNode) => void }> = ({ onSelectCommunity }) => {
|
||||
const { id } = useParams()
|
||||
const { t } = useTranslation()
|
||||
const [graphData, setGraphData] = useState<CommunityGraphData | null>(null)
|
||||
const [empty, setEmpty] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const controller = new AbortController()
|
||||
setEmpty(false)
|
||||
setGraphData(null)
|
||||
setLoading(true)
|
||||
getMemoryCommunityGraph(id, { signal: controller.signal }).then(res => {
|
||||
const raw = res as RawCommunityGraphData
|
||||
if (!raw.nodes?.length) { setEmpty(true); return }
|
||||
const built = buildCommunityGraphData(raw)
|
||||
if (!built) { setEmpty(true); return }
|
||||
setGraphData(built)
|
||||
}).catch((e) => { if (e?.code !== 'ERR_CANCELED') setEmpty(true) })
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return <Flex align="center" justify="center" className="rb:w-full rb:h-full">
|
||||
<Spin tip={t('userMemory.communityLoadingTip')} size="large">
|
||||
<div className="rb:w-64 rb:h-64" />
|
||||
</Spin>
|
||||
</Flex>
|
||||
}
|
||||
|
||||
return (
|
||||
<CommunityGraph
|
||||
data={graphData}
|
||||
empty={empty}
|
||||
showLegend={false}
|
||||
onCommunityClick={onSelectCommunity}
|
||||
renderTooltip={node => <NodeTooltip node={node} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityNetwork)
|
||||
@@ -1,74 +1,43 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-20 11:04:52
|
||||
*/
|
||||
/**
|
||||
* Conversation Memory Component
|
||||
* Displays RAG conversation memory content list
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Skeleton, List } from 'antd';
|
||||
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import Empty from '@/components/Empty';
|
||||
import PageScrollList from '@/components/PageScrollList'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import {
|
||||
getRagContent
|
||||
} from '@/api/memory'
|
||||
import { getRagContentUrl } from '@/api/memory'
|
||||
|
||||
const ConversationMemory:FC = () => {
|
||||
const ConversationMemory: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [list, setList] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getList()
|
||||
}, [id])
|
||||
/** Fetch conversation memory list */
|
||||
const getList = () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getRagContent(id).then((res) => {
|
||||
setList((res as { contents?: [] }).contents || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
<RbCard
|
||||
title={t('userMemory.conversationMemory')}
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
bodyClassName="rb:h-[100%]! rb:overflow-hidden rb:py-0!"
|
||||
>
|
||||
{loading
|
||||
? <Skeleton />
|
||||
: list.length > 0
|
||||
? <List
|
||||
dataSource={list}
|
||||
grid={{ gutter: 12, column: 1 }}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<div
|
||||
key={index}
|
||||
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
: <Empty className="rb:h-full" />
|
||||
}
|
||||
<PageScrollList<string>
|
||||
url={getRagContentUrl}
|
||||
query={{ end_user_id: id }}
|
||||
column={1}
|
||||
renderItem={(item: string) => (
|
||||
<div
|
||||
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
)}
|
||||
className="rb:h-full!"
|
||||
/>
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
export default ConversationMemory
|
||||
|
||||
export default ConversationMemory
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:32:23
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-16 15:01:50
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-20 11:07:02
|
||||
*/
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -107,7 +107,7 @@ const PerceptualLastInfo: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (!data.file_path) return
|
||||
window.open(data.file_path, '_blank')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:32:00
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 15:06:05
|
||||
* @Last Modified time: 2026-03-20 11:45:16
|
||||
*/
|
||||
/**
|
||||
* Relationship Network Component
|
||||
@@ -10,51 +10,60 @@
|
||||
* Interactive force-directed graph visualization
|
||||
*/
|
||||
|
||||
import React, { type FC, useEffect, useState, useCallback } from 'react'
|
||||
import React, { type FC, useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Space, Flex } from 'antd'
|
||||
import { Space, Tabs, Flex, Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import type { GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
|
||||
import type { RawCommunityNode } from '@/components/D3Graph/types'
|
||||
import {
|
||||
getMemorySearchEdges,
|
||||
} from '@/api/memory'
|
||||
import Tag from '@/components/Tag'
|
||||
import GraphNetworkChart, { type Node, type Edge } from '@/components/Charts/GraphNetworkChart'
|
||||
import CommunityNetwork from './CommunityNetwork'
|
||||
|
||||
const RelationshipNetwork:FC = () => {
|
||||
const RelationshipNetwork: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [links, setLinks] = useState<Edge[]>([])
|
||||
const [categories, setCategories] = useState<{ name: string }[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<Node | RawCommunityNode | null>(null)
|
||||
// const [fullScreen, setFullScreen] = useState<boolean>(false)
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState('relationshipNetwork')
|
||||
|
||||
console.log('categories', categories)
|
||||
const edgeAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
/** Fetch relationship network data */
|
||||
const getEdgeData = useCallback(() => {
|
||||
if (!id) return
|
||||
edgeAbortRef.current?.abort()
|
||||
edgeAbortRef.current = new AbortController()
|
||||
setSelectedNode(null)
|
||||
getMemorySearchEdges(id).then((res) => {
|
||||
getMemorySearchEdges(id, { signal: edgeAbortRef.current.signal }).then((res) => {
|
||||
const { nodes, edges, statistics } = res as GraphData
|
||||
const curNodes: Node[] = []
|
||||
const curEdges: Edge[] = []
|
||||
const curNodeTypes = Object.keys(statistics.node_types).filter(vo => vo !== 'Dialogue')
|
||||
|
||||
|
||||
// Calculate connection count for each node
|
||||
const connectionCount: Record<string, number> = {}
|
||||
edges.forEach(edge => {
|
||||
connectionCount[edge.source] = (connectionCount[edge.source] || 0) + 1
|
||||
connectionCount[edge.target] = (connectionCount[edge.target] || 0) + 1
|
||||
})
|
||||
|
||||
|
||||
// Process node data
|
||||
nodes.filter(vo => vo.label !== 'Dialogue').forEach(node => {
|
||||
const connections = connectionCount[node.id] || 0
|
||||
const categoryIndex = curNodeTypes.indexOf(node.label)
|
||||
|
||||
|
||||
// Get display name based on node type
|
||||
let displayName = ''
|
||||
switch (node.label) {
|
||||
@@ -80,7 +89,7 @@ const RelationshipNetwork:FC = () => {
|
||||
} else {
|
||||
symbolSize = 35
|
||||
}
|
||||
|
||||
|
||||
curNodes.push({
|
||||
...node,
|
||||
name: displayName,
|
||||
@@ -88,7 +97,7 @@ const RelationshipNetwork:FC = () => {
|
||||
symbolSize: symbolSize, // Adjust node size based on connection count
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Create mapping from node ID to label
|
||||
const nodeIdToLabel: Record<string, string> = {}
|
||||
nodes.forEach(node => {
|
||||
@@ -103,10 +112,10 @@ const RelationshipNetwork:FC = () => {
|
||||
value: edge.weight || 1
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Set categories
|
||||
const curCategories = curNodeTypes.map(type => ({ name: type }))
|
||||
|
||||
|
||||
setNodes(curNodes)
|
||||
setLinks(curEdges)
|
||||
setCategories(curCategories)
|
||||
@@ -115,6 +124,7 @@ const RelationshipNetwork:FC = () => {
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getEdgeData()
|
||||
return () => { edgeAbortRef.current?.abort() }
|
||||
}, [id])
|
||||
|
||||
/** Navigate to full graph view */
|
||||
@@ -123,21 +133,41 @@ const RelationshipNetwork:FC = () => {
|
||||
const params = new URLSearchParams({
|
||||
nodeId: selectedNode.id,
|
||||
nodeLabel: selectedNode.label,
|
||||
nodeName: selectedNode.name || ''
|
||||
nodeName: (selectedNode as Node).name || ''
|
||||
})
|
||||
navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`)
|
||||
}
|
||||
const handleChangeTab = (tab: string) => {
|
||||
if (tab === 'communityNetwork') {
|
||||
edgeAbortRef.current?.abort()
|
||||
} else {
|
||||
getEdgeData()
|
||||
}
|
||||
setActiveTab(tab)
|
||||
setSelectedNode(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:flex-1 rb:relative">
|
||||
<GraphNetworkChart
|
||||
nodes={nodes}
|
||||
links={links}
|
||||
categories={categories.map(vo => ({
|
||||
name: t(`userMemory.${vo.name}`)
|
||||
})) || []}
|
||||
onNodeClick={setSelectedNode}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleChangeTab}
|
||||
items={['relationshipNetwork', 'communityNetwork'].map(key => ({
|
||||
key,
|
||||
label: t(`userMemory.${key}`)
|
||||
}))}
|
||||
/>
|
||||
|
||||
{activeTab === 'communityNetwork'
|
||||
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
|
||||
: <GraphNetworkChart
|
||||
nodes={nodes}
|
||||
links={links}
|
||||
categories={categories.map(vo => ({
|
||||
name: t(`userMemory.${vo.name}`)
|
||||
})) || []}
|
||||
onNodeClick={(node) => setSelectedNode(node as Node)}
|
||||
/>
|
||||
}
|
||||
{selectedNode &&
|
||||
<RbCard
|
||||
title={t('userMemory.memoryDetails')}
|
||||
@@ -148,82 +178,100 @@ const RelationshipNetwork:FC = () => {
|
||||
extra={<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/close.svg')]" onClick={() => setSelectedNode(null)}></div>}
|
||||
>
|
||||
<div className="rb:max-h-[calc(100vh-269px)] rb:overflow-auto">
|
||||
{selectedNode.name &&
|
||||
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
|
||||
{selectedNode.name}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={24}>
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
|
||||
? selectedNode.properties.content
|
||||
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
|
||||
? selectedNode.properties.description
|
||||
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
||||
? selectedNode.properties.statement
|
||||
: ''
|
||||
}
|
||||
{(selectedNode as RawCommunityNode).properties.community_id
|
||||
? <div className="rb:p-3 rb:pt-0">
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[16px] rb:leading-5.5 rb:pl-1">
|
||||
{(selectedNode as RawCommunityNode).properties.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
<div className="rb:mt-3 rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.summary')}</div>
|
||||
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:px-3 rb:py-2.5 rb:mt-2">
|
||||
{(selectedNode as RawCommunityNode).properties.summary}
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:mt-5!">
|
||||
<span className="rb:text-[#5B6167] rb:font-regular rb:pl-1">{t('userMemory.member_count')}</span>
|
||||
<span className="rb:font-medium">{(selectedNode as RawCommunityNode).properties.member_count}{t('userMemory.member_count_desc')}</span>
|
||||
</Flex>
|
||||
|
||||
<Divider className='rb:my-2.5!' />
|
||||
<div className="rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.core_entities')}</div>
|
||||
<ul className="rb:list-disc rb:pl-4 rb:text-[#5B6167] rb:mt-2">
|
||||
{(selectedNode as RawCommunityNode).properties.core_entities.map((entity, index) => <li key={index}>{entity}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
: <>
|
||||
{(selectedNode as Node).name &&
|
||||
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
|
||||
{(selectedNode as Node).name}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={24}>
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
|
||||
? selectedNode.properties.content
|
||||
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
|
||||
? selectedNode.properties.description
|
||||
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
||||
? selectedNode.properties.statement
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode?.properties.associative_memory > 0 && <div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
|
||||
</div>
|
||||
</div>}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedNode as Node).properties.associative_memory > 0 && <div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
||||
<span className="rb:text-[#155EEF] rb:font-medium">{(selectedNode as Node).properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{selectedNode.label === 'Statement' && <>
|
||||
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
||||
const statementProps = selectedNode.properties as StatementNodeProperties;
|
||||
if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || typeof statementProps[key] === 'string') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{key === 'emotion_keywords'
|
||||
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
|
||||
: statementProps[key]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{selectedNode.label === 'Statement' && (<>
|
||||
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
||||
const p = selectedNode.properties as StatementNodeProperties
|
||||
if ((key === 'emotion_keywords' && p[key]?.length > 0) || typeof p[key] === 'string') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{key === 'emotion_keywords'
|
||||
? <Space>{p.emotion_keywords.map((v, i) => <Tag key={i}>{v}</Tag>)}</Space>
|
||||
: p[key]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>)}
|
||||
|
||||
{selectedNode.label === 'ExtractedEntity' && <>
|
||||
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
||||
const p = selectedNode.properties as ExtractedEntityNodeProperties
|
||||
if (p[key]) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{Array.isArray(p[key]) && p[key].length > 0
|
||||
? p[key].map((v, i) => <div key={i}>- {v}</div>)
|
||||
: p[key]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
</Flex>
|
||||
</>}
|
||||
|
||||
|
||||
{selectedNode.label === 'ExtractedEntity' && <>
|
||||
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
||||
const entityProps = selectedNode.properties as ExtractedEntityNodeProperties;
|
||||
if (entityProps[key]) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{Array.isArray(entityProps[key]) && entityProps[key].length > 0
|
||||
? entityProps[key].map((vo, index) => <div key={index}>- {vo}</div>)
|
||||
: entityProps[key]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Flex align="center" justify="center" className="rb:absolute rb:bottom-3 rb:left-6 rb:right-6 rb:border rb:border-[#171719] rb:rounded-xl rb:h-11 rb:font-medium rb:leading-5 rb:cursor-pointer" onClick={handleViewAll}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 11:49:52
|
||||
*/
|
||||
/**
|
||||
* User Memory Detail Types
|
||||
@@ -90,6 +90,7 @@ export interface ExtractedEntityNodeProperties {
|
||||
connect_strngth: string;
|
||||
importance_score: number;
|
||||
associative_memory: number;
|
||||
community_name?: string;
|
||||
}
|
||||
/**
|
||||
* Memory summary node
|
||||
@@ -246,4 +247,53 @@ export interface ForgetData {
|
||||
*/
|
||||
export interface GraphDetailRef {
|
||||
handleOpen: (vo: Node) => void
|
||||
}
|
||||
}
|
||||
// Community
|
||||
export type CommunityNodeType = 'Community' | 'ExtractedEntity';
|
||||
export type CommunityEdgeType = 'BELONGS_TO_COMMUNITY' | 'EXTRACTED_RELATIONSHIP';
|
||||
export type CommunityEntityType = "Person" | "Organization" | "ORG" | "Location" | "LOC" | "Event" | "Concept" | "Time" | "Position" | "WorkRole" | "System" | "Policy" | "HistoricalPeriod" | "HistoricalState" | "HistoricalEvent" | "EconomicFactor" | "Condition" | "Numeric" | "Work";
|
||||
// 社区节点
|
||||
export interface CommunityTypeNode {
|
||||
id: string;
|
||||
label: 'Community';
|
||||
properties: {
|
||||
community_id: string;
|
||||
end_user_id: string;
|
||||
member_count: number;
|
||||
updated_at: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
core_entities: string[];
|
||||
member_entity_ids: string[];
|
||||
};
|
||||
}
|
||||
// 核心实体
|
||||
export interface ExtractedEntityTypeNode {
|
||||
id: string;
|
||||
label: 'ExtractedEntity';
|
||||
properties: {
|
||||
name: string;
|
||||
end_user_id: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
entity_type: CommunityEntityType;
|
||||
community_name: string;
|
||||
};
|
||||
}
|
||||
// 社区图谱连线
|
||||
export interface CommunityEdge {
|
||||
id: string;
|
||||
target: string;
|
||||
source: string;
|
||||
}
|
||||
export interface CommunityStatistics {
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
node_types: Record<CommunityNodeType, number>;
|
||||
edge_types: Record<CommunityEdgeType, number>;
|
||||
}
|
||||
export interface CommunityGraphData {
|
||||
nodes: (CommunityTypeNode | ExtractedEntityTypeNode)[];
|
||||
edges: CommunityEdge[];
|
||||
statistics: CommunityStatistics;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { FC } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Select, Divider } from 'antd';
|
||||
// import { Node } from '@antv/x6';
|
||||
import type { GraphRef } from '../types'
|
||||
import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons'
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -9,20 +10,26 @@ interface CanvasToolbarProps {
|
||||
isHandMode: boolean;
|
||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
zoomLevel: number;
|
||||
addNotes: () => void;
|
||||
}
|
||||
|
||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
miniMapRef,
|
||||
graphRef,
|
||||
zoomLevel,
|
||||
// canUndo,
|
||||
// canRedo,
|
||||
// onUndo,
|
||||
// onRedo,
|
||||
addNotes,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 小地图 */}
|
||||
<div ref={miniMapRef} className="rb:absolute rb:bottom-15 rb:right-8 rb:z-1000 rb:rounded-lg rb:overflow-hidden"></div>
|
||||
{/* 缩放控制按钮 */}
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb-border rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2.25 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:gap-2 rb:z-1000">
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/minus.png')]" onClick={() => graphRef.current?.zoom(-0.1)}></div>
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:items-center rb:gap-4 rb:z-1000">
|
||||
<MinusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(-0.1)} />
|
||||
<Select
|
||||
value={Math.round(zoomLevel * 100)}
|
||||
onChange={(value: number | string) => {
|
||||
@@ -50,7 +57,9 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
variant='borderless'
|
||||
size="small"
|
||||
/>
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/plus.png')]" onClick={() => graphRef.current?.zoom(0.1)}></div>
|
||||
<PlusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(0.1)} />
|
||||
<Divider type="vertical" className="rb:h-4" />
|
||||
<FileAddOutlined onClick={addNotes} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:51:48
|
||||
* @Last Modified time: 2026-03-20 11:25:51
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
@@ -21,50 +21,59 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { forwardRef, useImperativeHandle, useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App, Space, Button, Flex, Dropdown, type MenuProps, Divider } from 'antd'
|
||||
import { App, Flex } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import RbDrawer from '@/components/RbDrawer';
|
||||
import VariableConfigModal from './VariableConfigModal'
|
||||
import { draftRun } from '@/api/application';
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types'
|
||||
import type { ChatRef, GraphRef, WorkflowConfig } from '../../types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import type { Variable } from '../Properties/VariableList/types'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import ChatToolbar from '@/components/Chat/ChatToolbar'
|
||||
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import Runtime from './Runtime';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null }>(({ appId, graphRef, data }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
// State management
|
||||
const [open, setOpen] = useState(false) // Drawer visibility
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
|
||||
(toolbarRef as React.MutableRefObject<ChatToolbarRef | null>).current = node
|
||||
}, [])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [variables, setVariables] = useState<Variable[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
/**
|
||||
* Opens the chat drawer and loads workflow variables from the start node
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
getVariables()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.features && open) setFeatures(data.features)
|
||||
}, [open, data?.features])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && graphRef.current && toolbarRef.current) {
|
||||
getVariables()
|
||||
}
|
||||
}, [open])
|
||||
/**
|
||||
* Extracts variables from the workflow's start node and merges with previous values
|
||||
*/
|
||||
@@ -84,7 +93,9 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
setVariables(curVariables)
|
||||
console.log('curVariables', curVariables)
|
||||
setVariables([...curVariables])
|
||||
toolbarRef.current?.setVariables([...curVariables])
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -96,22 +107,12 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setVariables([])
|
||||
setConversationId(null)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
toolbarRef.current?.setVariables([])
|
||||
setFileList([])
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
}
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
/**
|
||||
* Sends a message to execute the workflow
|
||||
*
|
||||
@@ -150,10 +151,14 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
|
||||
setLoading(true)
|
||||
const message = msg
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
@@ -338,13 +343,14 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
}
|
||||
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
const data = {
|
||||
message: message,
|
||||
variables: params,
|
||||
stream: true,
|
||||
conversation_id: conversationId,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -359,7 +365,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, data, handleStreamMessage)
|
||||
.catch((error) => {
|
||||
console.log('draftRun error', error)
|
||||
const errorInfo = JSON.parse(error.message)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
@@ -368,7 +374,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
...newList[lastIndex],
|
||||
status: 'failed',
|
||||
content: null,
|
||||
subContent: error.error
|
||||
subContent: errorInfo.error
|
||||
}
|
||||
}
|
||||
return newList
|
||||
@@ -379,65 +385,20 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current input message
|
||||
*/
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
*/
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds files from remote URL modal
|
||||
*/
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
toolbarRef.current?.setFiles([...list || []])
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
console.log('fileList', fileList)
|
||||
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<Flex align="center" gap={10}>
|
||||
{t('workflow.run')}
|
||||
{variables.length > 0 && <Space>
|
||||
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
|
||||
</Space>}
|
||||
</Flex>}
|
||||
classNames={{
|
||||
body: 'rb:p-0!'
|
||||
@@ -466,48 +427,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={handleSend}
|
||||
onChange={handleMessageChange}
|
||||
onChange={(msg) => setMessage(msg)}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<ChatToolbar
|
||||
ref={toolbarCallbackRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
onVariablesChange={setVariables}
|
||||
/>
|
||||
</ChatInput>
|
||||
</Flex>
|
||||
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbDrawer>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-24 17:57:08
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:48:09
|
||||
* @Last Modified time: 2026-03-12 13:39:24
|
||||
*/
|
||||
/*
|
||||
* Runtime Component
|
||||
@@ -225,12 +225,13 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
</div>
|
||||
)
|
||||
: <>
|
||||
{item.error
|
||||
? <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
|
||||
{item.error &&
|
||||
<div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 rb:mb-2 rb:-mt-4", getStatus('failed'))}>
|
||||
<Markdown content={item.error} />
|
||||
</div>
|
||||
: renderChild(item.subContent)
|
||||
}</>
|
||||
</div>
|
||||
}
|
||||
{renderChild(item.subContent)}
|
||||
</>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import RbModal from '@/components/RbModal'
|
||||
|
||||
interface VariableEditModalProps {
|
||||
refresh: (values: Variable[]) => void;
|
||||
variables: Variable[]
|
||||
}
|
||||
|
||||
const VariableConfigModal = forwardRef<VariableConfigModalRef, VariableEditModalProps>(({
|
||||
|
||||
@@ -25,6 +25,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
const textContent = root.getTextContent();
|
||||
if (textContent !== prevValueRef.current) {
|
||||
isUserInputRef.current = true;
|
||||
prevValueRef.current = textContent;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,7 +34,13 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) && !isUserInputRef.current) {
|
||||
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
|
||||
// Skip reset if the change was triggered by user input (avoid cursor jump)
|
||||
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
|
||||
prevValueRef.current = value;
|
||||
isUserInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection, $setSelection, $isTextNode, type BaseSelection } from 'lexical';
|
||||
import { $patchStyleText } from '@lexical/selection';
|
||||
import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, ListNode } from '@lexical/list';
|
||||
import { TOGGLE_LINK_COMMAND, LinkNode } from '@lexical/link';
|
||||
import { $getNearestNodeOfType } from '@lexical/utils';
|
||||
|
||||
export const NOTE_FORMAT_EVENT = 'note:format';
|
||||
|
||||
export interface FormatState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
strikethrough: boolean;
|
||||
list: boolean;
|
||||
fontSize?: number;
|
||||
linkUrl?: string | null;
|
||||
}
|
||||
|
||||
const NoteFormatPlugin = ({ nodeId, onFormatChange, fontSize = 12 }: { nodeId: string; fontSize?: number; onFormatChange?: (state: FormatState) => void }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const savedSelection = useRef<BaseSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) return;
|
||||
savedSelection.current = selection.clone();
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const style = 'getStyle' in anchorNode ? (anchorNode as { getStyle(): string }).getStyle() : '';
|
||||
const match = style.match(/font-size:\s*([\d.]+)px/);
|
||||
const nodeFontSize = match ? Number(match[1]) : fontSize;
|
||||
const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
|
||||
onFormatChange?.({
|
||||
bold: selection.hasFormat('bold'),
|
||||
italic: selection.hasFormat('italic'),
|
||||
strikethrough: selection.hasFormat('strikethrough'),
|
||||
list: !!$getNearestNodeOfType(anchorNode, ListNode),
|
||||
...(nodeFontSize ? { fontSize: nodeFontSize } : {}),
|
||||
linkUrl: linkNode ? linkNode.getURL() : null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [editor, onFormatChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, format, value } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
const sel = savedSelection.current;
|
||||
const hasSelection = $isRangeSelection(sel) && !sel.isCollapsed();
|
||||
if (format === 'link' && value === null) {
|
||||
// remove link: select the entire LinkNode first
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
const s = $getSelection();
|
||||
const anchorNode = $isRangeSelection(s)
|
||||
? s.anchor.getNode()
|
||||
: savedSelection.current && $isRangeSelection(savedSelection.current)
|
||||
? savedSelection.current.anchor.getNode()
|
||||
: null;
|
||||
const linkNode = anchorNode ? $getNearestNodeOfType(anchorNode, LinkNode) : null;
|
||||
if (linkNode) {
|
||||
const children = linkNode.getChildren();
|
||||
if (children.length > 0) {
|
||||
const first = children[0];
|
||||
const last = children[children.length - 1];
|
||||
if ($isTextNode(first) && $isTextNode(last)) {
|
||||
const range = first.select(0, 0);
|
||||
range.focus.set(last.getKey(), last.getTextContentSize(), 'text');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
});
|
||||
} else if (format === 'list') {
|
||||
editor.focus(() => {
|
||||
if (sel) editor.update(() => $setSelection(sel));
|
||||
editor.dispatchCommand(value ? INSERT_UNORDERED_LIST_COMMAND : REMOVE_LIST_COMMAND, undefined);
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
} else if (hasSelection) {
|
||||
editor.focus(() => {
|
||||
editor.update(() => $setSelection(sel));
|
||||
if (format === 'bold' || format === 'italic' || format === 'strikethrough') {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
} else if (format === 'link') {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, value as string | null);
|
||||
} else if (format === 'fontSize') {
|
||||
editor.update(() => {
|
||||
$setSelection(sel);
|
||||
$patchStyleText(sel!, { 'font-size': `${value}px` });
|
||||
});
|
||||
}
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
return () => window.removeEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
}, [editor, nodeId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default NoteFormatPlugin;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flex, Button, Input } from 'antd';
|
||||
import { EditOutlined, DisconnectOutlined } from '@ant-design/icons';
|
||||
|
||||
const POPOVER_STYLE: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
interface LinkPopoverProps {
|
||||
url: string;
|
||||
rect: DOMRect;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const LinkPopover: FC<LinkPopoverProps> = ({ url, rect, onEdit, onRemove }) => {
|
||||
const { t } = useTranslation();
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '4px 10px', fontSize: 12 }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex align="center" gap={8}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#2563eb', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block' }}>
|
||||
{url}
|
||||
</a>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit}>{t('common.edit')}</Button>
|
||||
<Button size="small" type="text" icon={<DisconnectOutlined />} onClick={onRemove}>{t('workflow.config.notes.removeLink')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
interface EditLinkPopoverProps {
|
||||
rect: DOMRect;
|
||||
initialUrl: string;
|
||||
onConfirm: (url: string) => void;
|
||||
}
|
||||
|
||||
export const EditLinkPopover: FC<EditLinkPopoverProps> = ({ rect, initialUrl, onConfirm }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const confirm = () => onConfirm(url);
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '8px' }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
size="small"
|
||||
className="rb:w-60!"
|
||||
placeholder={t('workflow.config.notes.enterLink')}
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onPressEnter={confirm}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={confirm}>{t('common.confirm')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { type FC, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { ListNode, ListItemNode } from '@lexical/list';
|
||||
import { LinkNode } from '@lexical/link';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import NoteFormatPlugin from './NoteFormatPlugin';
|
||||
import type { FormatState } from './NoteFormatPlugin';
|
||||
import { LinkPopover, EditLinkPopover } from './NoteLinkPopovers';
|
||||
|
||||
const theme = {
|
||||
paragraph: 'editor-paragraph',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
strikethrough: 'note-text-strikethrough',
|
||||
},
|
||||
list: { ul: 'note-list-ul', listitem: 'note-list-item' },
|
||||
link: 'note-link',
|
||||
};
|
||||
|
||||
const NOTE_NODES = [ListNode, ListItemNode, LinkNode];
|
||||
|
||||
const NOTE_STYLES = `
|
||||
.editor-text-bold { font-weight: bold; }
|
||||
.editor-text-italic { font-style: italic; }
|
||||
.note-text-strikethrough { text-decoration: line-through; }
|
||||
.note-list-ul { list-style-type: disc; padding-left: 1.2em; margin: 0; }
|
||||
.note-list-item { margin: 2px 0; }
|
||||
.note-link { color: #2563eb; text-decoration: underline; cursor: pointer; }
|
||||
`;
|
||||
|
||||
const NoteInitPlugin: FC<{ value: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const initialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialized.current || !value) return;
|
||||
initialized.current = true;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed?.root) {
|
||||
const state = editor.parseEditorState(JSON.stringify(parsed));
|
||||
editor.setEditorState(state);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}, [editor, value]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
interface NoteEditorProps {
|
||||
nodeId: string;
|
||||
value: string;
|
||||
fontSize?: number;
|
||||
onChange: (val: string) => void;
|
||||
onFormatChange?: (state: FormatState) => void;
|
||||
}
|
||||
|
||||
const NoteEditor: FC<NoteEditorProps> = ({ nodeId, value, fontSize = 12, onChange, onFormatChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [linkState, setLinkState] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const [editLinkRect, setEditLinkRect] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const removingLink = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkState) return;
|
||||
const handler = () => setLinkState(null);
|
||||
window.addEventListener('mousedown', handler);
|
||||
return () => window.removeEventListener('mousedown', handler);
|
||||
}, [!!linkState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, url, rect: passedRect } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
if (passedRect) {
|
||||
setEditLinkRect({ url: url || '', rect: passedRect });
|
||||
return;
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (r.width > 0 || r.height > 0) { setEditLinkRect({ url: url || '', rect: r }); return; }
|
||||
}
|
||||
const linkEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
const rect = linkEl?.getBoundingClientRect() ?? new DOMRect(window.innerWidth / 2, 200, 0, 0);
|
||||
setEditLinkRect({ url: url || '', rect });
|
||||
};
|
||||
window.addEventListener('note:edit-link', handler);
|
||||
return () => window.removeEventListener('note:edit-link', handler);
|
||||
}, [nodeId]);
|
||||
|
||||
const handleFormatChange = useCallback((state: FormatState) => {
|
||||
onFormatChange?.(state);
|
||||
if (state.linkUrl) {
|
||||
requestAnimationFrame(() => {
|
||||
if (removingLink.current) { removingLink.current = false; return; }
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
setLinkState({ url: state.linkUrl!, rect });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fallback: find the link element in the correct editor
|
||||
const editorEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
if (editorEl) {
|
||||
setLinkState({ url: state.linkUrl!, rect: editorEl.getBoundingClientRect() });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLinkState(null);
|
||||
}
|
||||
}, [onFormatChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{NOTE_STYLES}</style>
|
||||
<LexicalComposer initialConfig={{ namespace: `note-${nodeId}`, theme, nodes: NOTE_NODES, onError: console.error }}>
|
||||
<div style={{ position: 'relative' }} data-note-id={nodeId}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
style={{ minHeight: 60, outline: 'none', resize: 'none', fontSize: '12px', lineHeight: '18px', color: '#374151', overflow: 'auto', cursor: 'auto' }}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, color: '#9CA3AF', lineHeight: '18px', pointerEvents: 'none' }}>
|
||||
{t('workflow.config.notes.placeholder')}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<OnChangePlugin onChange={(editorState) => onChange(JSON.stringify(editorState.toJSON()))} />
|
||||
<NoteInitPlugin value={value} />
|
||||
<NoteFormatPlugin nodeId={nodeId} fontSize={fontSize} onFormatChange={handleFormatChange} />
|
||||
{editLinkRect && (
|
||||
<EditLinkPopover
|
||||
rect={editLinkRect.rect}
|
||||
initialUrl={editLinkRect.url}
|
||||
onConfirm={(url) => {
|
||||
removingLink.current = true;
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: url || null } }));
|
||||
setEditLinkRect(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{linkState && (
|
||||
<LinkPopover
|
||||
url={linkState.url}
|
||||
rect={linkState.rect}
|
||||
onEdit={() => {
|
||||
removingLink.current = true;
|
||||
const { rect, url } = linkState;
|
||||
setLinkState(null);
|
||||
setEditLinkRect({ url, rect });
|
||||
}}
|
||||
onRemove={() => {
|
||||
removingLink.current = true;
|
||||
setLinkState(null);
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: null } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteEditor;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type FC } from 'react';
|
||||
import { Flex, Dropdown, type MenuProps, Switch, Button, Divider } from 'antd';
|
||||
import { UnorderedListOutlined, BoldOutlined, ItalicOutlined, StrikethroughOutlined, LinkOutlined, DashOutlined } from '@ant-design/icons';
|
||||
import { Node } from '@antv/x6';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { THEME_MAP } from '../../../constant';
|
||||
const FONT_SIZES = [
|
||||
{ label: '小', value: 12 },
|
||||
{ label: '中', value: 14 },
|
||||
{ label: '大', value: 16 },
|
||||
];
|
||||
|
||||
interface NoteNodeToolbarProps {
|
||||
node: Node;
|
||||
onFormat: (type: string, value?: unknown) => void;
|
||||
toolConfig: Record<string, number | boolean>;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const NoteNodeToolbar: FC<NoteNodeToolbarProps> = ({ node, onFormat, toolConfig, nodeId }) => {
|
||||
const data = node?.getData() || {};
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colorItems: MenuProps['items'] = Object.entries(THEME_MAP).map(([key, theme]) => ({
|
||||
key,
|
||||
label: (
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: theme.bg }}
|
||||
onClick={() => onFormat('color', key)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const fontSizeItems: MenuProps['items'] = FONT_SIZES.map(({ label, value }) => ({
|
||||
key: value,
|
||||
label: <span onClick={() => onFormat('fontSize', value)}>{label}</span>,
|
||||
}));
|
||||
|
||||
const currentFontSize = FONT_SIZES.find(f => f.value === toolConfig.fontSize)?.label ?? '小';
|
||||
|
||||
const handleClick: MenuProps['onClick'] = (e) => {
|
||||
switch (e.key) {
|
||||
case 'delete':
|
||||
node.remove()
|
||||
break;
|
||||
case 'copy':
|
||||
break;
|
||||
}
|
||||
}
|
||||
const handleChange = (type: string) => {
|
||||
let show_author = data.config.show_author.defaultValue
|
||||
if(type === 'showAuth'){
|
||||
show_author = !show_author
|
||||
}
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
show_author: {
|
||||
...data.config.show_author,
|
||||
defaultValue: show_author
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
className="rb:absolute rb:-top-11 rb:left-1/2 rb:-translate-x-1/2 rb:bg-white rb:z-10 rb:whitespace-nowrap rb:rounded-lg rb:py-1! rb:px-3!"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Color picker */}
|
||||
<Dropdown menu={{ items: colorItems }} trigger={['click']}>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: THEME_MAP[data.bgColor]?.bg || THEME_MAP.blue.bg }}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Font size */}
|
||||
<Dropdown menu={{ items: fontSizeItems }} trigger={['click']}>
|
||||
<Flex align="center" gap={4} className="rb:cursor-pointer rb:text-xs rb:text-gray-600 rb:select-none">
|
||||
<span className="rb:text-xs">Aa</span>
|
||||
<span className="rb:text-xs">{currentFontSize}</span>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Bold */}
|
||||
<Button
|
||||
type={toolConfig.bold ? 'primary' : 'text'}
|
||||
icon={<BoldOutlined />}
|
||||
onClick={() => onFormat('bold')}
|
||||
/>
|
||||
|
||||
{/* Italic */}
|
||||
<Button
|
||||
type={toolConfig.italic ? 'primary' : 'text'}
|
||||
icon={<ItalicOutlined />}
|
||||
onClick={() => onFormat('italic')}
|
||||
/>
|
||||
|
||||
{/* Strikethrough */}
|
||||
<Button
|
||||
type={toolConfig.strikethrough ? 'primary' : 'text'}
|
||||
icon={<StrikethroughOutlined />}
|
||||
onClick={() => onFormat('strikethrough')}
|
||||
/>
|
||||
|
||||
{/* Link */}
|
||||
<Button
|
||||
type={toolConfig.link ? 'primary' : 'text'}
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => {
|
||||
const sel = window.getSelection();
|
||||
const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : undefined;
|
||||
window.dispatchEvent(new CustomEvent('note:edit-link', { detail: { id: nodeId, url: '', rect } }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* List */}
|
||||
<Button
|
||||
type={toolConfig.list ? 'primary' : 'text'}
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => onFormat('list')}
|
||||
/>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
// { key: 'copy', label: t('common.copy') },
|
||||
{
|
||||
key: 'showAuth',
|
||||
label: <Flex align="center" gap={24}>
|
||||
{t('workflow.config.notes.showAuth')}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={data.config.show_author.defaultValue}
|
||||
onChange={() => handleChange('showAuth')}
|
||||
/>
|
||||
</Flex>
|
||||
},
|
||||
{ key: 'delete', label: <Flex>{t('common.delete')}</Flex> },
|
||||
],
|
||||
onClick: handleClick
|
||||
}}
|
||||
>
|
||||
<DashOutlined />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNodeToolbar;
|
||||
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
import { Flex } from 'antd';
|
||||
|
||||
import NoteEditor from './NoteEditor';
|
||||
import NoteNodeToolbar from './NoteNodeToolbar';
|
||||
import { THEME_MAP } from '../../../constant'
|
||||
|
||||
const MIN_W = 240;
|
||||
const MIN_H = 120;
|
||||
|
||||
const NoteNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {};
|
||||
const nodeId = node?.id || '';
|
||||
const startRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [toolConfig, setToolConfig] = useState({
|
||||
fontSize: 12,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strikethrough: false,
|
||||
list: false,
|
||||
})
|
||||
|
||||
const handleFormat = (type: string, value?: unknown) => {
|
||||
console.log('handleFormat', type, value)
|
||||
if (type === 'color') {
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
theme: {
|
||||
...data.config.theme,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (type === 'fontSize') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'fontSize', value } }));
|
||||
} else if (type === 'link') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: value || null } }));
|
||||
} else if (type === 'list') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'list', value: !toolConfig.list } }));
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: type } }));
|
||||
}
|
||||
|
||||
setToolConfig(prev => ({ ...prev, [type]: value || !prev[type as unknown as keyof typeof toolConfig] }))
|
||||
};
|
||||
|
||||
const onResizeMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const size = node?.getSize();
|
||||
if (!size) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY, w: size.width, h: size.height };
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const w = Math.max(MIN_W, startRef.current.w + ev.clientX - startRef.current.x);
|
||||
const h = Math.max(MIN_H, startRef.current.h + ev.clientY - startRef.current.y);
|
||||
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
width: {
|
||||
...data.config.width,
|
||||
defaultValue: w
|
||||
},
|
||||
height: {
|
||||
...data.config.height,
|
||||
defaultValue: h
|
||||
}
|
||||
}
|
||||
});
|
||||
node?.prop('size', { width: w, height: h });
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
startRef.current = null;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const updateText = (value: string) => {
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
text: {
|
||||
...data.config.text,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const theme = THEME_MAP[data.config?.theme?.defaultValue || 'blue'] || THEME_MAP['blue']
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rb:relative rb:h-full rb:w-full rb:rounded-2xl rb:border"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
borderColor: data.isSelected ? theme.outer : theme.border,
|
||||
}}
|
||||
>
|
||||
<div className="rb:h-4 rb:rounded-tl-2xl rb:rounded-tr-2xl"
|
||||
style={{
|
||||
background: theme.title
|
||||
}}
|
||||
></div>
|
||||
{data.isSelected && <NoteNodeToolbar node={node!} nodeId={nodeId} toolConfig={toolConfig} onFormat={handleFormat} />}
|
||||
|
||||
<div
|
||||
className="rb:w-full rb:h-[calc(100%-36px)] rb:p-2.5 rb:overflow-auto"
|
||||
onMouseDown={e => {
|
||||
e.stopPropagation()
|
||||
node?.setData({ ...node.getData(), isSelected: true })
|
||||
}}
|
||||
onWheel={e => e.stopPropagation()}
|
||||
>
|
||||
<NoteEditor
|
||||
nodeId={nodeId}
|
||||
value={data.config.text.defaultValue || ''}
|
||||
fontSize={toolConfig.fontSize}
|
||||
onChange={updateText}
|
||||
onFormatChange={(state) => setToolConfig(prev => ({ ...prev, ...state }))}
|
||||
/>
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:pl-2.5! rb:pr-1!">
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">
|
||||
{data.config.show_author.defaultValue
|
||||
? data.config.author.defaultValue
|
||||
: undefined
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="rb:size-4 rb:border-b-[4px] rb:border-r-[4px] rb:border-[#EBEBEB] rb:rounded-2xl"></div> */}
|
||||
<div
|
||||
onMouseDown={onResizeMouseDown}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 9.75V6H13.5V9.75C13.5 11.8211 11.8211 13.5 9.75 13.5H6V12H9.75C10.9926 12 12 10.9926 12 9.75Z" fill="black" fillOpacity="0.16"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNode;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:30:28
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 11:49:30
|
||||
* @Last Modified time: 2026-03-20 11:24:26
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
@@ -148,18 +148,23 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
if (sourcePortGroup === 'left') {
|
||||
// Connect from left port to new node's right side
|
||||
targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right';
|
||||
graph.addEdge({
|
||||
source: { cell: newNode.id, port: targetPort },
|
||||
target: { cell: sourceNode.id, port: sourcePort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
} else {
|
||||
// Connect from right port to new node's left side
|
||||
targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
|
||||
graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: targetPort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
}
|
||||
|
||||
graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: targetPort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
|
||||
// Adjust loop node size when child node is added via port within loop node
|
||||
const cycleId = sourceNodeData.cycle;
|
||||
if (cycleId) {
|
||||
@@ -230,20 +235,27 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');
|
||||
const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration');
|
||||
|
||||
const sourcePortInfo = sourceNode?.getPorts().find((p: any) => p.id === sourcePort);
|
||||
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
|
||||
const isLeftPort = sourcePortGroup === 'left';
|
||||
|
||||
let filteredNodes;
|
||||
if (isChildOfLoop) {
|
||||
// Use same filtering as AddNode for child nodes of loop, but allow break
|
||||
// Use same filtering as AddNode for child nodes of loop, but allow break
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
|
||||
} else if (isChildOfIteration) {
|
||||
// Filter out loop and iteration nodes for children of iteration nodes, but allow break
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
|
||||
} else {
|
||||
// Original filtering for non-loop child nodes
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'break', 'cycle-start'].includes(nodeType.type));
|
||||
filteredNodes = category.nodes.filter(nodeType =>
|
||||
nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break'
|
||||
);
|
||||
}
|
||||
|
||||
if (isLeftPort) {
|
||||
filteredNodes = filteredNodes.filter(nodeType => nodeType.type !== 'end');
|
||||
}
|
||||
|
||||
if (filteredNodes.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 15:20:32
|
||||
* @Last Modified time: 2026-03-20 11:32:44
|
||||
*/
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -211,7 +211,9 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
</Form.Item>
|
||||
}
|
||||
{values?.body?.content_type === 'binary' &&
|
||||
<Form.Item name={['body', 'data']} noStyle>
|
||||
<Form.Item name={['body', 'data']}
|
||||
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!"
|
||||
>
|
||||
<Editor
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType.includes('file'))}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:16:13
|
||||
* @Last Modified time: 2026-03-20 11:25:59
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
@@ -86,7 +86,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNode && form) {
|
||||
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||
const { type = 'default', name = '', config, id } = selectedNode.getData() || {}
|
||||
const initialValue: Record<string, any> = {}
|
||||
Object.keys(config || {}).forEach(key => {
|
||||
if (config && config[key] && 'defaultValue' in config[key]) {
|
||||
@@ -96,7 +96,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
id,
|
||||
name,
|
||||
...initialValue,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:10:59
|
||||
* @Last Modified time: 2026-03-20 11:23:17
|
||||
*/
|
||||
import LoopNode from './components/Nodes/LoopNode';
|
||||
import NormalNode from './components/Nodes/NormalNode';
|
||||
import ConditionNode from './components/Nodes/ConditionNode';
|
||||
import GroupStartNode from './components/Nodes/GroupStartNode';
|
||||
import AddNode from './components/Nodes/AddNode'
|
||||
import NoteNode from './components/Nodes/NoteNode';
|
||||
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
@@ -475,10 +476,81 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export const THEME_MAP: Record<string, { outer: string; title: string; bg: string; border: string }> = {
|
||||
blue: {
|
||||
outer: '#2E90FA',
|
||||
title: '#D1E9FF',
|
||||
bg: '#EFF8FF',
|
||||
border: '#84CAFF',
|
||||
},
|
||||
cyan: {
|
||||
outer: '#06AED4',
|
||||
title: '#CFF9FE',
|
||||
bg: '#ECFDFF',
|
||||
border: '#67E3F9',
|
||||
},
|
||||
green: {
|
||||
outer: '#16B364',
|
||||
title: '#D3F8DF',
|
||||
bg: '#EDFCF2',
|
||||
border: '#73E2A3',
|
||||
},
|
||||
yellow: {
|
||||
outer: '#EAAA08',
|
||||
title: '#FEF7C3',
|
||||
bg: '#FEFBE8',
|
||||
border: '#FDE272',
|
||||
},
|
||||
pink: {
|
||||
outer: '#EE46BC',
|
||||
title: '#FCE7F6',
|
||||
bg: '#FDF2FA',
|
||||
border: '#FAA7E0',
|
||||
},
|
||||
violet: {
|
||||
outer: '#875BF7',
|
||||
title: '#ECE9FE',
|
||||
bg: '#F5F3FF',
|
||||
border: '#C3B5FD',
|
||||
},
|
||||
}
|
||||
|
||||
export const notesConfig = {
|
||||
type: "notes", icon: templateRenderingIcon,
|
||||
config: {
|
||||
text: {
|
||||
type: 'define',
|
||||
},
|
||||
theme: {
|
||||
type: 'define',
|
||||
defaultValue: 'blue',
|
||||
},
|
||||
width: {
|
||||
type: 'define',
|
||||
width: 240,
|
||||
},
|
||||
height: {
|
||||
type: 'define',
|
||||
height: 120,
|
||||
},
|
||||
author: {
|
||||
type: 'define',
|
||||
},
|
||||
show_author: {
|
||||
type: 'define',
|
||||
defaultValue: true
|
||||
}
|
||||
}
|
||||
}
|
||||
export const unknownNode = {
|
||||
type: 'unknown',
|
||||
icon: unknownIcon
|
||||
}
|
||||
export const noteNode = {
|
||||
type: 'notes',
|
||||
icon: unknownIcon
|
||||
}
|
||||
|
||||
export const nodeWidth = 240;
|
||||
|
||||
@@ -526,6 +598,12 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
||||
height: 28,
|
||||
component: AddNode,
|
||||
},
|
||||
{
|
||||
shape: 'notes-node',
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
component: NoteNode,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -658,7 +736,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
defaultPortItems[0],
|
||||
...(['IF', 'ELSE'].map((_, index) => ({
|
||||
group: 'right',
|
||||
id: `CASE${index}`,
|
||||
id: `CASE${index + 1}`,
|
||||
args: {
|
||||
...portArgs,
|
||||
y: portItemArgsY * index + conditionNodePortItemArgsY,
|
||||
@@ -677,7 +755,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
defaultPortItems[0],
|
||||
...(['分类1', '分类2'].map((_text, index) => ({
|
||||
group: 'right',
|
||||
id: `CASE${index}`,
|
||||
id: `CASE${index + 1}`,
|
||||
args: {
|
||||
...portArgs,
|
||||
y: portItemArgsY * index + conditionNodePortItemArgsY,
|
||||
@@ -758,6 +836,11 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
items: [defaultPortItems[0]],
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
shape: 'notes-node',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:17:48
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 14:49:17
|
||||
* @Last Modified time: 2026-03-20 11:26:43
|
||||
*/
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -12,9 +12,11 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
||||
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight } from '../constant';
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
import { useUser } from '@/store/user';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
@@ -24,6 +26,8 @@ export interface UseWorkflowGraphProps {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
/** Reference to the minimap container element */
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
/** Callback when features config is loaded */
|
||||
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +68,9 @@ export interface UseWorkflowGraphReturn {
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to update chat variables */
|
||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||
|
||||
handleAddNotes: () => void;
|
||||
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +82,13 @@ export interface UseWorkflowGraphReturn {
|
||||
export const useWorkflowGraph = ({
|
||||
containerRef,
|
||||
miniMapRef,
|
||||
onFeaturesLoad,
|
||||
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
|
||||
// Hooks
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation()
|
||||
const { user } = useUser();
|
||||
|
||||
// Refs
|
||||
const graphRef = useRef<Graph>();
|
||||
@@ -111,6 +120,7 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
setChatVariables(initChatVariables)
|
||||
setConfig({ ...rest, variables: initChatVariables })
|
||||
onFeaturesLoad?.(rest.features)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +138,7 @@ export const useWorkflowGraph = ({
|
||||
if (nodes.length) {
|
||||
const nodeList = nodes.map(node => {
|
||||
const { id, type, name, position, config = {} } = node
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode] }]
|
||||
let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === type) as NodeProperties
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ ...nodeLibraryConfig, config: nodeLibraryConfig.config || {} }))
|
||||
@@ -197,6 +207,13 @@ export const useWorkflowGraph = ({
|
||||
data: { ...node, ...nodeLibraryConfig},
|
||||
...position,
|
||||
}
|
||||
|
||||
if (type === 'notes') {
|
||||
const w = config.width;
|
||||
const h = config.height;
|
||||
if (w) nodeConfig.width = w as number;
|
||||
if (h) nodeConfig.height = h as number;
|
||||
}
|
||||
|
||||
// Generate ports dynamically for if-else node based on cases
|
||||
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
||||
@@ -471,11 +488,12 @@ export const useWorkflowGraph = ({
|
||||
*/
|
||||
const nodeClick = ({ node }: { node: Node }) => {
|
||||
// Ignore add-node type node clicks
|
||||
if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') {
|
||||
const nodeData = node.getData()
|
||||
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
|
||||
setSelectedNode(null)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
@@ -488,11 +506,13 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...node.getData(),
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
setSelectedNode(node);
|
||||
clearEdgeSelect()
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Handle edge click event
|
||||
@@ -601,7 +621,14 @@ export const useWorkflowGraph = ({
|
||||
*/
|
||||
const parseEvent = () => {
|
||||
if (!graphRef.current?.isClipboardEmpty()) {
|
||||
graphRef.current?.paste({ offset: 32 });
|
||||
const pastedNodes = graphRef.current?.paste({ offset: 32 }) ?? [];
|
||||
pastedNodes.forEach(cell => {
|
||||
if (cell.isNode()) {
|
||||
const data = cell.getData();
|
||||
const newId = `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
cell.setData({ ...data, id: newId });
|
||||
}
|
||||
});
|
||||
blankClick();
|
||||
}
|
||||
return false;
|
||||
@@ -749,6 +776,8 @@ export const useWorkflowGraph = ({
|
||||
panning: isHandMode,
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
factor: 0.1,
|
||||
modifiers: null,
|
||||
},
|
||||
connecting: {
|
||||
connector: {
|
||||
@@ -772,8 +801,23 @@ export const useWorkflowGraph = ({
|
||||
createEdge() {
|
||||
return graphRef.current?.createEdge(edgeAttrs);
|
||||
},
|
||||
validateConnection({ sourceCell, targetCell, targetMagnet }) {
|
||||
validateConnection({ sourceCell, targetCell, sourceMagnet, targetMagnet }) {
|
||||
if (!targetMagnet) return false;
|
||||
|
||||
// Only allow right port → left port connections
|
||||
const getPortGroup = (magnet: Element) => {
|
||||
let el: Element | null = magnet;
|
||||
while (el) {
|
||||
const group = el.getAttribute('port-group');
|
||||
if (group) return group;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const sourceGroup = sourceMagnet ? getPortGroup(sourceMagnet) : null;
|
||||
const targetGroup = targetMagnet ? getPortGroup(targetMagnet) : null;
|
||||
|
||||
if (sourceGroup === 'left' || targetGroup === 'right') return false;
|
||||
|
||||
// Node cannot connect to itself
|
||||
if (sourceCell?.id === targetCell?.id) return false;
|
||||
@@ -872,8 +916,31 @@ export const useWorkflowGraph = ({
|
||||
init();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
const handleNoteKeydown = (e: KeyboardEvent) => {
|
||||
if (!graphRef.current) return;
|
||||
const selectedNote = graphRef.current.getNodes().find(n => n.getData()?.isSelected && n.getData()?.type === 'notes');
|
||||
if (!selectedNote) return;
|
||||
const isMeta = e.ctrlKey || e.metaKey;
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// Only delete node when editor is not focused on text
|
||||
const active = document.activeElement;
|
||||
if (active && (active as HTMLElement).isContentEditable) return;
|
||||
deleteEvent();
|
||||
} else if (isMeta && e.key === 'c') {
|
||||
copyEvent();
|
||||
} else if (isMeta && e.key === 'v') {
|
||||
parseEvent();
|
||||
} else if (isMeta && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
deleteEvent();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleNoteKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('keydown', handleNoteKeydown);
|
||||
graphRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
@@ -897,7 +964,7 @@ export const useWorkflowGraph = ({
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === dragData.type);
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
|
||||
|
||||
// Create clean node data, only keep necessary fields
|
||||
const cleanNodeData = {
|
||||
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
@@ -948,6 +1015,9 @@ export const useWorkflowGraph = ({
|
||||
}) || [];
|
||||
const edges = graphRef.current?.getEdges() || []
|
||||
|
||||
|
||||
console.log('config', config)
|
||||
|
||||
const params = {
|
||||
...config,
|
||||
variables: chatVariables.map(v => {
|
||||
@@ -1116,6 +1186,35 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNotes = () => {
|
||||
if (!graphRef.current) return;
|
||||
const nodeConfig: NodeProperties = JSON.parse(JSON.stringify(notesConfig));
|
||||
nodeConfig.config = {
|
||||
...nodeConfig.config,
|
||||
author: { type: 'define', defaultValue: user?.username || '' },
|
||||
};
|
||||
const cleanNodeData = {
|
||||
id: `notes_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: t('workflow.notes'),
|
||||
...nodeConfig,
|
||||
};
|
||||
const container = graphRef.current.container;
|
||||
const nodeW = graphNodeLibrary.notes?.width || nodeWidth;
|
||||
const nodeH = graphNodeLibrary.notes?.height || 100;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const center = graphRef.current.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
graphRef.current.addNode({
|
||||
...(graphNodeLibrary.notes || graphNodeLibrary.default),
|
||||
x: center.x - nodeW / 2,
|
||||
y: center.y - nodeH / 2,
|
||||
id: cleanNodeData.id,
|
||||
data: { ...cleanNodeData },
|
||||
});
|
||||
}
|
||||
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
|
||||
setConfig(prev => prev ? { ...prev, features: value } as WorkflowConfig : prev)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
@@ -1133,6 +1232,8 @@ export const useWorkflowGraph = ({
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes,
|
||||
handleSaveFeaturesConfig
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,12 +6,12 @@ import Properties from './components/Properties';
|
||||
import CanvasToolbar from './components/CanvasToolbar';
|
||||
import PortClickHandler from './components/PortClickHandler';
|
||||
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
|
||||
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
|
||||
import type { WorkflowRef, FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import Chat from './components/Chat/Chat';
|
||||
import type { ChatRef, AddChatVariableRef } from './types'
|
||||
import AddChatVariable from './components/AddChatVariable';
|
||||
|
||||
const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const miniMapRef = useRef<HTMLDivElement>(null);
|
||||
const addChatVariableRef = useRef<AddChatVariableRef>(null)
|
||||
@@ -32,8 +32,10 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||
setChatVariables,
|
||||
handleAddNotes,
|
||||
handleSaveFeaturesConfig
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -53,7 +55,9 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
handleRun,
|
||||
graphRef,
|
||||
addVariable,
|
||||
config
|
||||
config,
|
||||
features: config?.features,
|
||||
handleSaveFeaturesConfig
|
||||
}))
|
||||
return (
|
||||
<div className="rb:h-[calc(100vh-64px)] rb:relative">
|
||||
@@ -74,6 +78,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
isHandMode={isHandMode}
|
||||
setIsHandMode={setIsHandMode}
|
||||
zoomLevel={zoomLevel}
|
||||
addNotes={handleAddNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +97,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
}
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
data={config}
|
||||
graphRef={graphRef}
|
||||
appId={config?.app_id as string}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import type { KnowledgeConfig } from './components/Properties/Knowledge/types'
|
||||
import type { Variable } from './components/Properties/VariableList/types'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
export interface NodeConfig {
|
||||
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string;
|
||||
placeholder?: string;
|
||||
@@ -89,6 +90,8 @@ export interface WorkflowConfig {
|
||||
is_active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
|
||||
features?: FeaturesConfigForm;
|
||||
}
|
||||
|
||||
export interface ChatRef {
|
||||
|
||||
Reference in New Issue
Block a user