mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
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:
committed by
GitHub
parent
8560a1e571
commit
8336efec41
28
core/http/react-ui/package-lock.json
generated
28
core/http/react-ui/package-lock.json
generated
@@ -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"
|
||||
|
||||
372
core/http/react-ui/src/components/UnifiedMCPDropdown.jsx
Normal file
372
core/http/react-ui/src/components/UnifiedMCPDropdown.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user