fix(ui): correctly display backend if specified in the model config, re-order MCP buttons (#9053)

fix(ui): correctly display backend if specified in the model config

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-03-18 09:58:25 +01:00
committed by GitHub
parent 8560a1e571
commit 8336efec41
6 changed files with 442 additions and 294 deletions

View File

@@ -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"

View File

@@ -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 (
<div className="chat-mcp-dropdown" ref={ref}>
<button
type="button"
className={`btn btn-sm ${totalBadge > 0 ? 'btn-primary' : 'btn-secondary'}`}
title="MCP servers, prompts, and resources"
onClick={handleOpen}
>
<i className="fas fa-plug" /> MCP
{totalBadge > 0 && (
<span className="chat-mcp-badge">{totalBadge}</span>
)}
</button>
{open && (
<div className="chat-mcp-dropdown-menu" style={{ minWidth: '300px' }}>
{/* Tab bar */}
<div className="unified-mcp-tabs">
{tabs.map(tab => (
<button
key={tab.key}
type="button"
className={`unified-mcp-tab${activeTab === tab.key ? ' unified-mcp-tab-active' : ''}`}
onClick={() => switchTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{/* Servers tab */}
{activeTab === 'servers' && serverMCPAvailable && (
mcpServersLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading servers...</div>
) : mcpServerList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP servers configured</div>
) : (
<>
<div className="chat-mcp-dropdown-header">
<span>MCP Servers</span>
<button type="button" className="chat-mcp-select-all" onClick={onSelectAllServers}>
{mcpServerList.every(s => selectedServers.includes(s.name)) ? 'Deselect all' : 'Select all'}
</button>
</div>
{mcpServerList.map(server => (
<label key={server.name} className="chat-mcp-server-item">
<input
type="checkbox"
checked={selectedServers.includes(server.name)}
onChange={() => onToggleServer(server.name)}
/>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{server.name}</span>
<span className="chat-mcp-server-tools">{server.tools?.length || 0} tools</span>
</div>
</label>
))}
</>
)
)}
{/* Client tab */}
{activeTab === 'client' && (
<>
<div className="chat-mcp-dropdown-header">
<span>Client MCP Servers</span>
<button type="button" className="chat-mcp-select-all" onClick={() => setAddDialog(!addDialog)}>
<i className="fas fa-plus" /> Add
</button>
</div>
{addDialog && (
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--color-border)' }}>
<input
type="text"
className="input input-sm"
placeholder="Server URL (e.g. https://mcp.example.com/sse)"
value={url}
onChange={e => setUrl(e.target.value)}
style={{ width: '100%', marginBottom: '4px' }}
/>
<input
type="text"
className="input input-sm"
placeholder="Name (optional)"
value={name}
onChange={e => setName(e.target.value)}
style={{ width: '100%', marginBottom: '4px' }}
/>
<input
type="password"
className="input input-sm"
placeholder="Auth token (optional)"
value={authToken}
onChange={e => setAuthToken(e.target.value)}
style={{ width: '100%', marginBottom: '4px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.8rem', marginBottom: '6px' }}>
<input type="checkbox" checked={useProxy} onChange={e => setUseProxy(e.target.checked)} />
Use CORS proxy
</label>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-sm btn-secondary" onClick={() => setAddDialog(false)}>Cancel</button>
<button type="button" className="btn btn-sm btn-primary" onClick={handleAddClient} disabled={!url.trim()}>Add</button>
</div>
</div>
)}
{clientServers.length === 0 && !addDialog ? (
<div className="chat-mcp-dropdown-empty">No client MCP servers configured</div>
) : (
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 (
<label key={server.id} className="chat-mcp-server-item">
<input
type="checkbox"
checked={isActive}
onChange={() => onClientToggle(server.id)}
/>
<div className="chat-mcp-server-info" style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`chat-client-mcp-status chat-client-mcp-status-${status}`} />
<span className="chat-mcp-server-name">{server.name}</span>
{server.headers?.Authorization && <i className="fas fa-lock" style={{ fontSize: '0.65rem', opacity: 0.5 }} title="Authenticated" />}
</div>
<span className="chat-mcp-server-tools">
{status === 'connecting' ? 'Connecting...' :
status === 'error' ? (connectionStatuses[server.id]?.error || 'Error') :
status === 'connected' && connTools ? `${connTools.tools.length} tools` :
server.url}
</span>
</div>
<button
className="btn btn-sm"
style={{ padding: '2px 6px', fontSize: '0.7rem', color: 'var(--color-error)' }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleRemoveClient(server.id) }}
title="Remove server"
>
<i className="fas fa-trash" />
</button>
</label>
)
})
)}
</>
)}
{/* Prompts tab */}
{activeTab === 'prompts' && promptsAvailable && (
<>
{promptArgsDialog ? (
<>
<div className="chat-mcp-dropdown-header">
<span>{promptArgsDialog.title || promptArgsDialog.name}</span>
</div>
{promptArgsDialog.arguments.map(arg => (
<div key={arg.name} style={{ padding: '4px 10px' }}>
<label style={{ fontSize: '0.8rem', display: 'block', marginBottom: '2px' }}>
{arg.name}{arg.required ? ' *' : ''}
</label>
<input
type="text"
className="input input-sm"
style={{ width: '100%' }}
placeholder={arg.description || arg.name}
value={promptArgsValues[arg.name] || ''}
onChange={e => onPromptArgsChange(arg.name, e.target.value)}
/>
</div>
))}
<div style={{ padding: '6px 10px', display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-sm btn-secondary" onClick={onPromptArgsCancel}>Cancel</button>
<button type="button" className="btn btn-sm btn-primary" onClick={onPromptArgsSubmit}>Apply</button>
</div>
</>
) : mcpPromptsLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading prompts...</div>
) : mcpPromptList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP prompts available</div>
) : (
<>
<div className="chat-mcp-dropdown-header"><span>MCP Prompts</span></div>
{mcpPromptList.map(prompt => (
<div
key={prompt.name}
className="chat-mcp-server-item"
style={{ cursor: 'pointer', padding: '6px 10px' }}
onClick={() => onSelectPrompt(prompt)}
>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{prompt.title || prompt.name}</span>
{prompt.description && (
<span className="chat-mcp-server-tools">{prompt.description}</span>
)}
</div>
</div>
))}
</>
)}
</>
)}
{/* Resources tab */}
{activeTab === 'resources' && resourcesAvailable && (
mcpResourcesLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading resources...</div>
) : mcpResourceList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP resources available</div>
) : (
<>
<div className="chat-mcp-dropdown-header"><span>MCP Resources</span></div>
{mcpResourceList.map(resource => (
<label key={resource.uri} className="chat-mcp-server-item">
<input
type="checkbox"
checked={selectedResources.includes(resource.uri)}
onChange={() => onToggleResource(resource.uri)}
/>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{resource.name}</span>
<span className="chat-mcp-server-tools">{resource.uri}</span>
</div>
</label>
))}
</>
)
)}
</div>
)}
<style>{`
.unified-mcp-tabs {
display: flex;
border-bottom: 1px solid var(--color-border-subtle);
padding: 0 4px;
}
.unified-mcp-tab {
flex: 1;
background: none;
border: none;
padding: 6px 8px;
font-size: 0.75rem;
font-family: inherit;
color: var(--color-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color var(--duration-fast), border-color var(--duration-fast);
}
.unified-mcp-tab:hover {
color: var(--color-text-primary);
}
.unified-mcp-tab-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 500;
}
`}</style>
</div>
)
}

View File

@@ -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 && (
<span className="badge badge-info" style={{ fontSize: '0.75rem' }}>{modelInfo.backend}</span>
)}
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowModelInfo(!showModelInfo)}
@@ -899,170 +863,40 @@ export default function Chat() {
</button>
</>
)}
{mcpAvailable && (
<div className="chat-mcp-dropdown" ref={mcpDropdownRef}>
<button
className={`btn btn-sm ${(activeChat.mcpServers?.length > 0) ? 'btn-primary' : 'btn-secondary'}`}
title="Select MCP servers"
onClick={() => { setMcpServersOpen(!mcpServersOpen); if (!mcpServersOpen) fetchMcpServers() }}
>
<i className="fas fa-plug" /> MCP
{activeChat.mcpServers?.length > 0 && (
<span className="chat-mcp-badge">{activeChat.mcpServers.length}</span>
)}
</button>
{mcpServersOpen && (
<div className="chat-mcp-dropdown-menu">
{mcpServersLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading servers...</div>
) : mcpServerList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP servers configured</div>
) : (
<>
<div className="chat-mcp-dropdown-header">
<span>MCP Servers</span>
<button
className="chat-mcp-select-all"
onClick={() => {
const allNames = mcpServerList.map(s => s.name)
const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n))
updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames })
}}
>
{mcpServerList.every(s => (activeChat.mcpServers || []).includes(s.name)) ? 'Deselect all' : 'Select all'}
</button>
</div>
{mcpServerList.map(server => (
<label key={server.name} className="chat-mcp-server-item">
<input
type="checkbox"
checked={(activeChat.mcpServers || []).includes(server.name)}
onChange={() => toggleMcpServer(server.name)}
/>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{server.name}</span>
<span className="chat-mcp-server-tools">{server.tools?.length || 0} tools</span>
</div>
</label>
))}
</>
)}
</div>
)}
</div>
)}
{mcpAvailable && (
<div className="chat-mcp-dropdown" ref={mcpPromptsRef}>
<button
className="btn btn-sm btn-secondary"
title="MCP Prompts"
onClick={() => { setMcpPromptsOpen(!mcpPromptsOpen); if (!mcpPromptsOpen) fetchMcpPrompts() }}
>
<i className="fas fa-comment-dots" /> Prompts
</button>
{mcpPromptsOpen && (
<div className="chat-mcp-dropdown-menu">
{mcpPromptsLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading prompts...</div>
) : mcpPromptList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP prompts available</div>
) : (
<>
<div className="chat-mcp-dropdown-header"><span>MCP Prompts</span></div>
{mcpPromptList.map(prompt => (
<div
key={prompt.name}
className="chat-mcp-server-item"
style={{ cursor: 'pointer', padding: '6px 10px' }}
onClick={() => handleSelectPrompt(prompt)}
>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{prompt.title || prompt.name}</span>
{prompt.description && (
<span className="chat-mcp-server-tools">{prompt.description}</span>
)}
</div>
</div>
))}
</>
)}
</div>
)}
{mcpPromptArgsDialog && (
<div className="chat-mcp-dropdown-menu" style={{ minWidth: '250px' }}>
<div className="chat-mcp-dropdown-header">
<span>{mcpPromptArgsDialog.title || mcpPromptArgsDialog.name}</span>
</div>
{mcpPromptArgsDialog.arguments.map(arg => (
<div key={arg.name} style={{ padding: '4px 10px' }}>
<label style={{ fontSize: '0.8rem', display: 'block', marginBottom: '2px' }}>
{arg.name}{arg.required ? ' *' : ''}
</label>
<input
type="text"
className="input input-sm"
style={{ width: '100%' }}
placeholder={arg.description || arg.name}
value={mcpPromptArgsValues[arg.name] || ''}
onChange={e => setMcpPromptArgsValues(prev => ({ ...prev, [arg.name]: e.target.value }))}
/>
</div>
))}
<div style={{ padding: '6px 10px', display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-secondary" onClick={() => setMcpPromptArgsDialog(null)}>Cancel</button>
<button className="btn btn-sm btn-primary" onClick={handleExpandPromptWithArgs}>Apply</button>
</div>
</div>
)}
</div>
)}
{mcpAvailable && (
<div className="chat-mcp-dropdown" ref={mcpResourcesRef}>
<button
className={`btn btn-sm ${(activeChat.mcpResources?.length > 0) ? 'btn-primary' : 'btn-secondary'}`}
title="MCP Resources"
onClick={() => { setMcpResourcesOpen(!mcpResourcesOpen); if (!mcpResourcesOpen) fetchMcpResources() }}
>
<i className="fas fa-paperclip" /> Resources
{activeChat.mcpResources?.length > 0 && (
<span className="chat-mcp-badge">{activeChat.mcpResources.length}</span>
)}
</button>
{mcpResourcesOpen && (
<div className="chat-mcp-dropdown-menu">
{mcpResourcesLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading resources...</div>
) : mcpResourceList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP resources available</div>
) : (
<>
<div className="chat-mcp-dropdown-header"><span>MCP Resources</span></div>
{mcpResourceList.map(resource => (
<label key={resource.uri} className="chat-mcp-server-item">
<input
type="checkbox"
checked={(activeChat.mcpResources || []).includes(resource.uri)}
onChange={() => toggleMcpResource(resource.uri)}
/>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{resource.name}</span>
<span className="chat-mcp-server-tools">{resource.uri}</span>
</div>
</label>
))}
</>
)}
</div>
)}
</div>
)}
<ClientMCPDropdown
activeServerIds={activeChat.clientMCPServers || []}
onToggleServer={handleClientMCPToggle}
onServerAdded={handleClientMCPServerAdded}
onServerRemoved={handleClientMCPServerRemoved}
<UnifiedMCPDropdown
serverMCPAvailable={mcpAvailable}
mcpServerList={mcpServerList}
mcpServersLoading={mcpServersLoading}
selectedServers={activeChat.mcpServers || []}
onToggleServer={toggleMcpServer}
onSelectAllServers={() => {
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}
/>
<div className="chat-header-actions">
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { apiUrl } from '../utils/basePath'
import ModelSelector from '../components/ModelSelector'
import ClientMCPDropdown from '../components/ClientMCPDropdown'
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
import { useResources } from '../hooks/useResources'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi } from '../utils/api'
import { API_CONFIG } from '../utils/config'
@@ -37,13 +37,11 @@ export default function Home() {
const [textFiles, setTextFiles] = useState([])
const [mcpMode, setMcpMode] = useState(false)
const [mcpAvailable, setMcpAvailable] = useState(false)
const [mcpServersOpen, setMcpServersOpen] = useState(false)
const [mcpServerList, setMcpServerList] = useState([])
const [mcpServersLoading, setMcpServersLoading] = useState(false)
const [mcpServerCache, setMcpServerCache] = useState({})
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
const mcpDropdownRef = useRef(null)
const [placeholderIdx, setPlaceholderIdx] = useState(0)
const [placeholderText, setPlaceholderText] = useState('')
const imageInputRef = useRef(null)
@@ -140,17 +138,6 @@ export default function Home() {
else setTextFiles(removeFn)
}, [])
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 () => {
if (!selectedModel) return
if (mcpServerCache[selectedModel]) {
@@ -251,67 +238,24 @@ export default function Home() {
{/* Model selector + MCP toggle */}
<div className="home-model-row">
<ModelSelector value={selectedModel} onChange={setSelectedModel} capability="FLAG_CHAT" />
{mcpAvailable && (
<div className="chat-mcp-dropdown" ref={mcpDropdownRef}>
<button
type="button"
className={`btn btn-sm ${mcpSelectedServers.length > 0 ? 'btn-primary' : 'btn-secondary'}`}
title="Select MCP servers"
onClick={() => { setMcpServersOpen(!mcpServersOpen); if (!mcpServersOpen) fetchMcpServers() }}
>
<i className="fas fa-plug" /> MCP
{mcpSelectedServers.length > 0 && (
<span className="chat-mcp-badge">{mcpSelectedServers.length}</span>
)}
</button>
{mcpServersOpen && (
<div className="chat-mcp-dropdown-menu">
{mcpServersLoading ? (
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading servers...</div>
) : mcpServerList.length === 0 ? (
<div className="chat-mcp-dropdown-empty">No MCP servers configured</div>
) : (
<>
<div className="chat-mcp-dropdown-header">
<span>MCP Servers</span>
<button
type="button"
className="chat-mcp-select-all"
onClick={() => {
const allNames = mcpServerList.map(s => s.name)
const allSelected = allNames.every(n => mcpSelectedServers.includes(n))
setMcpSelectedServers(allSelected ? [] : allNames)
}}
>
{mcpServerList.every(s => mcpSelectedServers.includes(s.name)) ? 'Deselect all' : 'Select all'}
</button>
</div>
{mcpServerList.map(server => (
<label key={server.name} className="chat-mcp-server-item">
<input
type="checkbox"
checked={mcpSelectedServers.includes(server.name)}
onChange={() => toggleMcpServer(server.name)}
/>
<div className="chat-mcp-server-info">
<span className="chat-mcp-server-name">{server.name}</span>
<span className="chat-mcp-server-tools">{server.tools?.length || 0} tools</span>
</div>
</label>
))}
</>
)}
</div>
)}
</div>
)}
<ClientMCPDropdown
activeServerIds={clientMCPSelectedIds}
onToggleServer={(id) => setClientMCPSelectedIds(prev =>
<UnifiedMCPDropdown
serverMCPAvailable={mcpAvailable}
mcpServerList={mcpServerList}
mcpServersLoading={mcpServersLoading}
selectedServers={mcpSelectedServers}
onToggleServer={toggleMcpServer}
onSelectAllServers={() => {
const allNames = mcpServerList.map(s => s.name)
const allSelected = allNames.every(n => mcpSelectedServers.includes(n))
setMcpSelectedServers(allSelected ? [] : allNames)
}}
onFetchServers={fetchMcpServers}
clientMCPActiveIds={clientMCPSelectedIds}
onClientToggle={(id) => setClientMCPSelectedIds(prev =>
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
)}
onServerAdded={(server) => setClientMCPSelectedIds(prev => [...prev, server.id])}
onServerRemoved={(id) => setClientMCPSelectedIds(prev => prev.filter(s => s !== id))}
onClientAdded={(server) => setClientMCPSelectedIds(prev => [...prev, server.id])}
onClientRemoved={(id) => setClientMCPSelectedIds(prev => prev.filter(s => s !== id))}
/>
</div>

View File

@@ -226,7 +226,7 @@ export default function Manage() {
)}
</td>
<td>
<span className="badge badge-info">Auto</span>
<span className="badge badge-info">{model.backend || 'Auto'}</span>
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>

View File

@@ -471,6 +471,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
type modelCapability struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Backend string `json:"backend"`
}
result := make([]modelCapability, 0, len(modelConfigs)+len(modelsWithoutConfig))
@@ -478,6 +479,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
result = append(result, modelCapability{
ID: cfg.Name,
Capabilities: cfg.KnownUsecaseStrings,
Backend: cfg.Backend,
})
}
for _, name := range modelsWithoutConfig {