diff --git a/core/http/react-ui/package-lock.json b/core/http/react-ui/package-lock.json index 96e8cc62b..f9c1e5e17 100644 --- a/core/http/react-ui/package-lock.json +++ b/core/http/react-ui/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.27.0", - "@playwright/test": "^1.52.0", + "@playwright/test": "1.52.0", "@vitejs/plugin-react": "^4.5.2", "eslint": "^9.27.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -1048,13 +1048,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -3069,13 +3068,12 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -3088,11 +3086,10 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "dev": true, - "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -3106,7 +3103,6 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" diff --git a/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx new file mode 100644 index 000000000..fe0bedc31 --- /dev/null +++ b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx @@ -0,0 +1,372 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { loadClientMCPServers, addClientMCPServer, removeClientMCPServer } from '../utils/mcpClientStorage' + +export default function UnifiedMCPDropdown({ + // Server MCP props + serverMCPAvailable = false, + mcpServerList = [], + mcpServersLoading = false, + selectedServers = [], + onToggleServer, + onSelectAllServers, + onFetchServers, + // Client MCP props + clientMCPActiveIds = [], + onClientToggle, + onClientAdded, + onClientRemoved, + connectionStatuses = {}, + getConnectedTools, + // Prompts props (optional, Chat only) + promptsAvailable = false, + mcpPromptList = [], + mcpPromptsLoading = false, + onFetchPrompts, + onSelectPrompt, + promptArgsDialog = null, + promptArgsValues = {}, + onPromptArgsChange, + onPromptArgsSubmit, + onPromptArgsCancel, + // Resources props (optional, Chat only) + resourcesAvailable = false, + mcpResourceList = [], + mcpResourcesLoading = false, + onFetchResources, + selectedResources = [], + onToggleResource, +}) { + const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState(() => serverMCPAvailable ? 'servers' : 'client') + const [addDialog, setAddDialog] = useState(false) + const [clientServers, setClientServers] = useState(() => loadClientMCPServers()) + const [url, setUrl] = useState('') + const [name, setName] = useState('') + const [authToken, setAuthToken] = useState('') + const [useProxy, setUseProxy] = useState(true) + const ref = useRef(null) + + // Update default tab when serverMCPAvailable changes + useEffect(() => { + if (!serverMCPAvailable && activeTab === 'servers') { + setActiveTab('client') + } + }, [serverMCPAvailable]) + + // Click outside to close + useEffect(() => { + if (!open) return + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + const handleOpen = useCallback(() => { + if (!open) { + // Fetch data for default tab + if (serverMCPAvailable && activeTab === 'servers' && onFetchServers) onFetchServers() + else if (activeTab === 'prompts' && onFetchPrompts) onFetchPrompts() + else if (activeTab === 'resources' && onFetchResources) onFetchResources() + } + setOpen(!open) + }, [open, activeTab, serverMCPAvailable, onFetchServers, onFetchPrompts, onFetchResources]) + + const switchTab = useCallback((tab) => { + setActiveTab(tab) + if (tab === 'servers' && onFetchServers) onFetchServers() + else if (tab === 'prompts' && onFetchPrompts) onFetchPrompts() + else if (tab === 'resources' && onFetchResources) onFetchResources() + }, [onFetchServers, onFetchPrompts, onFetchResources]) + + const handleAddClient = useCallback(() => { + if (!url.trim()) return + const headers = {} + if (authToken.trim()) { + headers.Authorization = `Bearer ${authToken.trim()}` + } + const server = addClientMCPServer({ name: name.trim() || undefined, url: url.trim(), headers, useProxy }) + setClientServers(loadClientMCPServers()) + setUrl('') + setName('') + setAuthToken('') + setUseProxy(true) + setAddDialog(false) + if (onClientAdded) onClientAdded(server) + }, [url, name, authToken, useProxy, onClientAdded]) + + const handleRemoveClient = useCallback((id) => { + removeClientMCPServer(id) + setClientServers(loadClientMCPServers()) + if (onClientRemoved) onClientRemoved(id) + }, [onClientRemoved]) + + const totalBadge = (selectedServers?.length || 0) + (clientMCPActiveIds?.length || 0) + (selectedResources?.length || 0) + + const tabs = [] + if (serverMCPAvailable) tabs.push({ key: 'servers', label: 'Servers' }) + tabs.push({ key: 'client', label: 'Client' }) + if (promptsAvailable) tabs.push({ key: 'prompts', label: 'Prompts' }) + if (resourcesAvailable) tabs.push({ key: 'resources', label: 'Resources' }) + + return ( +
+ + {open && ( +
+ {/* Tab bar */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Servers tab */} + {activeTab === 'servers' && serverMCPAvailable && ( + mcpServersLoading ? ( +
Loading servers...
+ ) : mcpServerList.length === 0 ? ( +
No MCP servers configured
+ ) : ( + <> +
+ MCP Servers + +
+ {mcpServerList.map(server => ( + + ))} + + ) + )} + + {/* Client tab */} + {activeTab === 'client' && ( + <> +
+ Client MCP Servers + +
+ {addDialog && ( +
+ setUrl(e.target.value)} + style={{ width: '100%', marginBottom: '4px' }} + /> + setName(e.target.value)} + style={{ width: '100%', marginBottom: '4px' }} + /> + setAuthToken(e.target.value)} + style={{ width: '100%', marginBottom: '4px' }} + /> + +
+ + +
+
+ )} + {clientServers.length === 0 && !addDialog ? ( +
No client MCP servers configured
+ ) : ( + clientServers.map(server => { + const status = connectionStatuses[server.id]?.status || 'disconnected' + const isActive = clientMCPActiveIds.includes(server.id) + const connTools = getConnectedTools?.().find(c => c.serverId === server.id) + return ( + + ) + }) + )} + + )} + + {/* Prompts tab */} + {activeTab === 'prompts' && promptsAvailable && ( + <> + {promptArgsDialog ? ( + <> +
+ {promptArgsDialog.title || promptArgsDialog.name} +
+ {promptArgsDialog.arguments.map(arg => ( +
+ + onPromptArgsChange(arg.name, e.target.value)} + /> +
+ ))} +
+ + +
+ + ) : mcpPromptsLoading ? ( +
Loading prompts...
+ ) : mcpPromptList.length === 0 ? ( +
No MCP prompts available
+ ) : ( + <> +
MCP Prompts
+ {mcpPromptList.map(prompt => ( +
onSelectPrompt(prompt)} + > +
+ {prompt.title || prompt.name} + {prompt.description && ( + {prompt.description} + )} +
+
+ ))} + + )} + + )} + + {/* Resources tab */} + {activeTab === 'resources' && resourcesAvailable && ( + mcpResourcesLoading ? ( +
Loading resources...
+ ) : mcpResourceList.length === 0 ? ( +
No MCP resources available
+ ) : ( + <> +
MCP Resources
+ {mcpResourceList.map(resource => ( + + ))} + + ) + )} +
+ )} + + +
+ ) +} diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 071d6830a..84cab9041 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -8,7 +8,7 @@ import CanvasPanel from '../components/CanvasPanel' import { fileToBase64, modelsApi, mcpApi } from '../utils/api' import { useMCPClient } from '../hooks/useMCPClient' import MCPAppFrame from '../components/MCPAppFrame' -import ClientMCPDropdown from '../components/ClientMCPDropdown' +import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown' import { loadClientMCPServers } from '../utils/mcpClientStorage' function relativeTime(ts) { @@ -299,16 +299,13 @@ export default function Chat() { const [editingName, setEditingName] = useState(null) const [editName, setEditName] = useState('') const [mcpAvailable, setMcpAvailable] = useState(false) - const [mcpServersOpen, setMcpServersOpen] = useState(false) const [mcpServerList, setMcpServerList] = useState([]) const [mcpServersLoading, setMcpServersLoading] = useState(false) const [mcpServerCache, setMcpServerCache] = useState({}) - const [mcpPromptsOpen, setMcpPromptsOpen] = useState(false) const [mcpPromptList, setMcpPromptList] = useState([]) const [mcpPromptsLoading, setMcpPromptsLoading] = useState(false) const [mcpPromptArgsDialog, setMcpPromptArgsDialog] = useState(null) const [mcpPromptArgsValues, setMcpPromptArgsValues] = useState({}) - const [mcpResourcesOpen, setMcpResourcesOpen] = useState(false) const [mcpResourceList, setMcpResourceList] = useState([]) const [mcpResourcesLoading, setMcpResourcesLoading] = useState(false) const [chatSearch, setChatSearch] = useState('') @@ -366,18 +363,6 @@ export default function Chat() { return () => { cancelled = true } }, [activeChat?.model]) - const mcpDropdownRef = useRef(null) - useEffect(() => { - if (!mcpServersOpen) return - const handleClick = (e) => { - if (mcpDropdownRef.current && !mcpDropdownRef.current.contains(e.target)) { - setMcpServersOpen(false) - } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [mcpServersOpen]) - const fetchMcpServers = useCallback(async () => { const model = activeChat?.model if (!model) return @@ -407,30 +392,6 @@ export default function Chat() { updateChatSettings(activeChat.id, { mcpServers: next }) }, [activeChat, updateChatSettings]) - const mcpPromptsRef = useRef(null) - useEffect(() => { - if (!mcpPromptsOpen) return - const handleClick = (e) => { - if (mcpPromptsRef.current && !mcpPromptsRef.current.contains(e.target)) { - setMcpPromptsOpen(false) - } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [mcpPromptsOpen]) - - const mcpResourcesRef = useRef(null) - useEffect(() => { - if (!mcpResourcesOpen) return - const handleClick = (e) => { - if (mcpResourcesRef.current && !mcpResourcesRef.current.contains(e.target)) { - setMcpResourcesOpen(false) - } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [mcpResourcesOpen]) - const fetchMcpPrompts = useCallback(async () => { const model = activeChat?.model if (!model) return @@ -478,7 +439,7 @@ export default function Chat() { } catch (e) { addMessage(activeChat.id, { role: 'system', content: `Failed to expand prompt: ${e.message}` }) } - setMcpPromptsOpen(false) + }, [activeChat?.model, activeChat?.id, addMessage]) const handleExpandPromptWithArgs = useCallback(async () => { @@ -497,7 +458,7 @@ export default function Chat() { } setMcpPromptArgsDialog(null) setMcpPromptArgsValues({}) - setMcpPromptsOpen(false) + }, [activeChat?.model, activeChat?.id, mcpPromptArgsDialog, mcpPromptArgsValues, addMessage]) const toggleMcpResource = useCallback((uri) => { @@ -883,6 +844,9 @@ export default function Chat() { /> {activeChat.model && ( <> + {modelInfo?.backend && ( + {modelInfo.backend} + )} )} - {mcpAvailable && ( -
- - {mcpServersOpen && ( -
- {mcpServersLoading ? ( -
Loading servers...
- ) : mcpServerList.length === 0 ? ( -
No MCP servers configured
- ) : ( - <> -
- MCP Servers - -
- {mcpServerList.map(server => ( - - ))} - - )} -
- )} -
- )} - {mcpAvailable && ( -
- - {mcpPromptsOpen && ( -
- {mcpPromptsLoading ? ( -
Loading prompts...
- ) : mcpPromptList.length === 0 ? ( -
No MCP prompts available
- ) : ( - <> -
MCP Prompts
- {mcpPromptList.map(prompt => ( -
handleSelectPrompt(prompt)} - > -
- {prompt.title || prompt.name} - {prompt.description && ( - {prompt.description} - )} -
-
- ))} - - )} -
- )} - {mcpPromptArgsDialog && ( -
-
- {mcpPromptArgsDialog.title || mcpPromptArgsDialog.name} -
- {mcpPromptArgsDialog.arguments.map(arg => ( -
- - setMcpPromptArgsValues(prev => ({ ...prev, [arg.name]: e.target.value }))} - /> -
- ))} -
- - -
-
- )} -
- )} - {mcpAvailable && ( -
- - {mcpResourcesOpen && ( -
- {mcpResourcesLoading ? ( -
Loading resources...
- ) : mcpResourceList.length === 0 ? ( -
No MCP resources available
- ) : ( - <> -
MCP Resources
- {mcpResourceList.map(resource => ( - - ))} - - )} -
- )} -
- )} - { + const allNames = mcpServerList.map(s => s.name) + const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n)) + updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames }) + }} + onFetchServers={fetchMcpServers} + clientMCPActiveIds={activeChat.clientMCPServers || []} + onClientToggle={handleClientMCPToggle} + onClientAdded={handleClientMCPServerAdded} + onClientRemoved={handleClientMCPServerRemoved} connectionStatuses={connectionStatuses} getConnectedTools={getConnectedTools} + promptsAvailable={mcpAvailable} + mcpPromptList={mcpPromptList} + mcpPromptsLoading={mcpPromptsLoading} + onFetchPrompts={fetchMcpPrompts} + onSelectPrompt={handleSelectPrompt} + promptArgsDialog={mcpPromptArgsDialog} + promptArgsValues={mcpPromptArgsValues} + onPromptArgsChange={(name, value) => setMcpPromptArgsValues(prev => ({ ...prev, [name]: value }))} + onPromptArgsSubmit={handleExpandPromptWithArgs} + onPromptArgsCancel={() => setMcpPromptArgsDialog(null)} + resourcesAvailable={mcpAvailable} + mcpResourceList={mcpResourceList} + mcpResourcesLoading={mcpResourcesLoading} + onFetchResources={fetchMcpResources} + selectedResources={activeChat.mcpResources || []} + onToggleResource={toggleMcpResource} />