feat(connector): multi-select connectors in chat composer

- Replace single selectedConnector with selectedConnectors array
- Send ext_info.connector_ids (array) to backend (Task C contract)
- Render one tag per selected connector with individual onClose
- Popover stays open after pick; click toggles membership
- Badge shows count; tooltip shows comma-joined names
- ChatMessage.attachedConnectors[] for replay
This commit is contained in:
alan.cl
2026-05-26 13:03:24 +08:00
parent babd975465
commit 5da697034f
6 changed files with 340 additions and 3 deletions

View File

@@ -52,6 +52,7 @@ export const ChatEn = {
use_skill: 'Use Skill',
use_knowledge: 'Use Knowledge Base',
use_database: 'Use Database',
use_connector: 'Select Connector',
execution_steps: 'Execution Steps',
db_gpt_computer: "DB-GPT's Computer",
load_skill: 'Load Skill',

View File

@@ -60,6 +60,7 @@ export const ChatZh: Resources['translation'] = {
use_skill: '使用技能',
use_knowledge: '使用知识库',
use_database: '使用数据库',
use_connector: '选择连接器',
execution_steps: '执行步骤',
db_gpt_computer: 'DB-GPT 的电脑',
load_skill: '加载技能',

View File

@@ -1,5 +1,6 @@
import MarkdownContext from '@/new-components/common/MarkdownContext';
import {
ApiOutlined,
AppstoreOutlined,
BarChartOutlined,
BookOutlined,
@@ -34,6 +35,7 @@ import classNames from 'classnames';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ObservationFormatter from './ObservationFormatter';
import { AttachedConnector } from '@/new-components/connector/types';
export type StepStatus = 'pending' | 'running' | 'completed' | 'error';
@@ -118,6 +120,7 @@ export interface ManusLeftPanelProps {
db_name: string;
db_type: string;
};
attachedConnectors?: AttachedConnector[];
createdSkillName?: string;
onSkillCardClick?: (skillName: string) => void;
onSkillDownload?: (skillName: string) => void;
@@ -810,6 +813,7 @@ const ManusLeftPanel: React.FC<ManusLeftPanelProps> = ({
attachedKnowledge,
attachedSkill,
attachedDb,
attachedConnectors,
createdSkillName,
onSkillCardClick,
onSkillDownload,
@@ -921,6 +925,21 @@ const ManusLeftPanel: React.FC<ManusLeftPanelProps> = ({
</div>
</div>
)}
{(attachedConnectors ?? []).map(c => (
<div key={c.id} className='flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700/60 bg-white dark:bg-[#1a1b1e] shadow-sm'>
<div className='w-8 h-8 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center flex-shrink-0'>
<ApiOutlined className='text-violet-500 text-base' />
</div>
<div className='min-w-0 flex-1'>
<div className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
{c.display_name}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>
{c.connector_type}
</div>
</div>
</div>
))}
<div className='rounded-2xl bg-gray-100 dark:bg-[#2a2b2f] px-4 py-3 text-sm text-gray-800 dark:text-gray-200 leading-relaxed'>
{userQuery}
</div>

View File

@@ -2,5 +2,5 @@ export { default as ConnectorCard } from './ConnectorCard';
export { default as ConnectorForm } from './ConnectorForm';
export { default as ConfirmDialog } from './ConfirmDialog';
export { useConfirmPolling } from './useConfirmPolling';
export type { ConnectorCatalogEntry, ConnectorInstance, ConnectorStatus, CreateConnectorRequest } from './types';
export type { AttachedConnector, ConnectorCatalogEntry, ConnectorInstance, ConnectorStatus, CreateConnectorRequest } from './types';
export type { PendingConfirmation, ConfirmActionRequest } from './types';

View File

@@ -1,5 +1,11 @@
export type ConnectorStatus = 'active' | 'error' | 'disconnected';
export type AttachedConnector = {
id: string;
connector_type: string;
display_name: string;
};
export interface ConnectorInstance {
id: string;
connector_type: string;

View File

@@ -13,9 +13,12 @@ import ManusRightPanel, {
PanelView,
} from '@/new-components/chat/content/ManusRightPanel';
import { MessagePart, ToolPart, ToolStatus } from '@/new-components/chat/content/OpenCodeSessionTurn';
import { ConnectorInstance, AttachedConnector } from '@/new-components/connector/types';
import { useConnectors } from '@/hooks/use-connector-api';
import axios from '@/utils/ctx-axios';
import { sendSpacePostRequest } from '@/utils/request';
import {
ApiOutlined,
ArrowUpOutlined,
AudioOutlined,
BarChartOutlined,
@@ -161,6 +164,7 @@ interface ChatMessage {
attachedKnowledge?: KnowledgeSpace;
attachedSkill?: { name: string; id: string };
attachedDb?: { db_name: string; db_type: string };
attachedConnectors?: AttachedConnector[];
}
interface ExecutionStep {
@@ -573,6 +577,11 @@ const Playground: NextPage = () => {
const [isDbPanelOpen, setIsDbPanelOpen] = useState(false);
const [dbSearchQuery, setDbSearchQuery] = useState('');
const [isConnectorPanelOpen, setIsConnectorPanelOpen] = useState(false);
const [selectedConnectors, setSelectedConnectors] = useState<ConnectorInstance[]>([]);
const [connectorSearchQuery, setConnectorSearchQuery] = useState('');
const { connectors: connectorsList } = useConnectors();
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
const [rightPanelView, setRightPanelView] = useState<PanelView>('execution');
@@ -1493,6 +1502,9 @@ const Playground: NextPage = () => {
attachedKnowledge: selectedKnowledge ?? undefined,
attachedSkill: effectiveSkill ? { name: effectiveSkill.name, id: effectiveSkill.id } : undefined,
attachedDb: effectiveDb ? { db_name: effectiveDb.db_name, db_type: effectiveDb.db_type } : undefined,
attachedConnectors: selectedConnectors.length > 0
? selectedConnectors.map(c => ({ id: c.id, connector_type: c.connector_type, display_name: c.display_name }))
: undefined,
},
{
id: responseId,
@@ -1543,6 +1555,9 @@ const Playground: NextPage = () => {
...(selectedKnowledge
? { knowledge_space_name: selectedKnowledge.name, knowledge_space_id: selectedKnowledge.id }
: {}),
...(selectedConnectors.length > 0
? { connector_ids: selectedConnectors.map(c => c.id) }
: {}),
},
}),
signal: controller.signal,
@@ -2316,6 +2331,7 @@ const Playground: NextPage = () => {
attachedKnowledge={round.humanMsg?.attachedKnowledge}
attachedSkill={round.humanMsg?.attachedSkill}
attachedDb={round.humanMsg?.attachedDb}
attachedConnectors={round.humanMsg?.attachedConnectors}
assistantText={roundAssistantText}
modelName={round.viewMsg?.model_name || model}
stepThoughts={stepThoughts}
@@ -2437,6 +2453,20 @@ const Playground: NextPage = () => {
<BookOutlined /> <span className='font-medium ml-1'>{selectedKnowledge.name}</span>
</Tag>
)}
{selectedConnectors.length > 0 && (
<>
{selectedConnectors.map(c => (
<Tag
key={c.id}
closable
onClose={() => setSelectedConnectors(prev => prev.filter(s => s.id !== c.id))}
className='flex items-center gap-1 bg-violet-50 border-violet-200 text-violet-700 px-3 py-1 rounded-full'
>
<ApiOutlined /> <span className='font-medium ml-1'>{c.display_name}</span>
</Tag>
))}
</>
)}
{uploadedFile && (
<Tag
closable
@@ -2504,6 +2534,12 @@ const Playground: NextPage = () => {
icon: <BookOutlined />,
onClick: () => setIsKnowledgeModalOpen(true),
},
{
key: 'connector',
label: t('use_connector'),
icon: <ApiOutlined />,
onClick: () => setIsConnectorPanelOpen(true),
},
],
}}
trigger={['click']}
@@ -2653,6 +2689,133 @@ const Playground: NextPage = () => {
</Tooltip>
</Popover>
{/* Connector Selector Button */}
<Popover
trigger='click'
placement='topLeft'
open={isConnectorPanelOpen}
onOpenChange={setIsConnectorPanelOpen}
overlayClassName='manus-skill-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder='搜索连接器...'
prefix={<SearchOutlined className='text-gray-400' />}
value={connectorSearchQuery}
onChange={e => setConnectorSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(connectorsList || [])
.filter(
(c: ConnectorInstance) =>
!connectorSearchQuery ||
c.display_name.toLowerCase().includes(connectorSearchQuery.toLowerCase()) ||
c.connector_type.toLowerCase().includes(connectorSearchQuery.toLowerCase()),
)
.map((c: ConnectorInstance) => (
<div
key={c.id}
onClick={() => {
setSelectedConnectors(prev =>
prev.some(s => s.id === c.id)
? prev.filter(s => s.id !== c.id)
: [...prev, c]
);
setConnectorSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedConnectors.some(s => s.id === c.id) ? 'bg-violet-50 dark:bg-violet-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center text-white text-xs'>
<ApiOutlined />
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{c.display_name}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
c.status === 'active'
? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{c.status === 'active' ? '已激活' : c.status}
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5'>
{c.connector_type}
</p>
</div>
{selectedConnectors.some(s => s.id === c.id) && (
<CheckCircleFilled className='text-violet-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(connectorsList || []).filter(
(c: ConnectorInstance) =>
!connectorSearchQuery ||
c.display_name.toLowerCase().includes(connectorSearchQuery.toLowerCase()) ||
c.connector_type.toLowerCase().includes(connectorSearchQuery.toLowerCase()),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<ApiOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{connectorSearchQuery ? '未找到匹配的连接器' : '暂无可用连接器'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(connectorsList || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/construct/connectors');
setIsConnectorPanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip title={selectedConnectors.length === 0 ? '选择连接器' : selectedConnectors.length === 1 ? `连接器: ${selectedConnectors[0].display_name}` : `已选 ${selectedConnectors.length} 个连接器: ${selectedConnectors.map(c => c.display_name).join('、')}`}>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedConnectors.length > 0
? 'bg-gradient-to-br from-[#8b5cf6] to-[#6366f1] text-white border border-transparent shadow-[0_2px_4px_rgba(139,92,246,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(139,92,246,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<ApiOutlined className={selectedConnectors.length > 0 ? 'text-white' : ''} />
{selectedConnectors.length > 0 && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-violet-600 text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-violet-500/30'>
{selectedConnectors.length}
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Separator dot */}
<div className='w-px h-4 bg-gray-200 dark:bg-gray-700 mx-0.5' />
@@ -2838,8 +3001,8 @@ const Playground: NextPage = () => {
<div className='w-full relative transition-all duration-500 rounded-[28px] shadow-[0_16px_48px_rgba(0,0,0,0.12),0_6px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_24px_64px_rgba(0,0,0,0.2),0_12px_32px_rgba(0,0,0,0.1)] dark:shadow-[0_16px_48px_rgba(0,0,0,0.4)] dark:hover:shadow-[0_24px_64px_rgba(0,0,0,0.5)]'>
{/* White Inner Box - Clean Glass Card */}
<div className='bg-white/95 backdrop-blur-md dark:bg-[#1e1f24]/95 rounded-[28px] border border-gray-100 dark:border-[#33353b] shadow-[inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] p-5 relative z-10'>
{/* Uploaded File, Database, Knowledge Tags */}
{(uploadedFile || selectedDb || selectedKnowledge) && (
{/* Uploaded File, Database, Knowledge, Connector Tags */}
{(uploadedFile || selectedDb || selectedKnowledge || selectedConnectors.length > 0) && (
<div className='flex flex-wrap gap-2 mb-2'>
{uploadedFile && (
<Tag
@@ -2869,6 +3032,20 @@ const Playground: NextPage = () => {
<BookOutlined /> <span className='font-medium ml-1'>{selectedKnowledge.name}</span>
</Tag>
)}
{selectedConnectors.length > 0 && (
<>
{selectedConnectors.map(c => (
<Tag
key={c.id}
closable
onClose={() => setSelectedConnectors(prev => prev.filter(s => s.id !== c.id))}
className='flex items-center gap-1 bg-violet-50 border-violet-200 text-violet-700 px-3 py-1 rounded-full'
>
<ApiOutlined /> <span className='font-medium ml-1'>{c.display_name}</span>
</Tag>
))}
</>
)}
</div>
)}
@@ -2930,6 +3107,12 @@ const Playground: NextPage = () => {
icon: <DatabaseOutlined />,
onClick: () => setTimeout(() => setIsDbPanelOpen(true), 100),
},
{
key: 'connector',
label: t('use_connector'),
icon: <ApiOutlined />,
onClick: () => setIsConnectorPanelOpen(true),
},
],
}}
trigger={['click']}
@@ -3079,6 +3262,133 @@ const Playground: NextPage = () => {
</Tooltip>
</Popover>
{/* Connector Selector Button */}
<Popover
trigger='click'
placement='topLeft'
open={isConnectorPanelOpen}
onOpenChange={setIsConnectorPanelOpen}
overlayClassName='manus-skill-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder='搜索连接器...'
prefix={<SearchOutlined className='text-gray-400' />}
value={connectorSearchQuery}
onChange={e => setConnectorSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(connectorsList || [])
.filter(
(c: ConnectorInstance) =>
!connectorSearchQuery ||
c.display_name.toLowerCase().includes(connectorSearchQuery.toLowerCase()) ||
c.connector_type.toLowerCase().includes(connectorSearchQuery.toLowerCase()),
)
.map((c: ConnectorInstance) => (
<div
key={c.id}
onClick={() => {
setSelectedConnectors(prev =>
prev.some(s => s.id === c.id)
? prev.filter(s => s.id !== c.id)
: [...prev, c]
);
setConnectorSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedConnectors.some(s => s.id === c.id) ? 'bg-violet-50 dark:bg-violet-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center text-white text-xs'>
<ApiOutlined />
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{c.display_name}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
c.status === 'active'
? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{c.status === 'active' ? '已激活' : c.status}
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5'>
{c.connector_type}
</p>
</div>
{selectedConnectors.some(s => s.id === c.id) && (
<CheckCircleFilled className='text-violet-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(connectorsList || []).filter(
(c: ConnectorInstance) =>
!connectorSearchQuery ||
c.display_name.toLowerCase().includes(connectorSearchQuery.toLowerCase()) ||
c.connector_type.toLowerCase().includes(connectorSearchQuery.toLowerCase()),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<ApiOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{connectorSearchQuery ? '未找到匹配的连接器' : '暂无可用连接器'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(connectorsList || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/construct/connectors');
setIsConnectorPanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip title={selectedConnectors.length === 0 ? '选择连接器' : selectedConnectors.length === 1 ? `连接器: ${selectedConnectors[0].display_name}` : `已选 ${selectedConnectors.length} 个连接器: ${selectedConnectors.map(c => c.display_name).join('、')}`}>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedConnectors.length > 0
? 'bg-gradient-to-br from-[#8b5cf6] to-[#6366f1] text-white border border-transparent shadow-[0_2px_4px_rgba(139,92,246,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(139,92,246,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<ApiOutlined className={selectedConnectors.length > 0 ? 'text-white' : ''} />
{selectedConnectors.length > 0 && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-violet-600 text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-violet-500/30'>
{selectedConnectors.length}
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Database Selector Popover - Blue themed */}
<Popover
trigger='click'