mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Merge pull request #161 from MervinPraison/claude/issue-154-20260624-0952
feat: MCP manager view + Ctrl+K command palette (B-08, B-02)
This commit is contained in:
4 files changed
+353
-1
No files matched your search
@@ -3314,6 +3314,14 @@ def create_app(
|
||||
"description": "Execution nodes & presence",
|
||||
"order": 15,
|
||||
},
|
||||
{
|
||||
"id": "mcp",
|
||||
"title": "MCP",
|
||||
"icon": "🔌",
|
||||
"group": "Agent",
|
||||
"description": "Model Context Protocol servers",
|
||||
"order": 30,
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"title": "Config",
|
||||
@@ -3408,6 +3416,7 @@ def create_app(
|
||||
"sessions": "/sessions",
|
||||
"cron": "/api/schedules",
|
||||
"channels": "/api/channels",
|
||||
"mcp": "/api/mcp/servers",
|
||||
}
|
||||
|
||||
# Read page whitelist from config.yaml if set_pages() was not called
|
||||
|
||||
@@ -174,6 +174,7 @@ const BUILTIN_VIEWS = {
|
||||
'jobs-board': '/plugins/views/jobs-board.js',
|
||||
auth: '/plugins/views/auth.js',
|
||||
api: '/plugins/views/api.js',
|
||||
mcp: '/plugins/views/mcp.js',
|
||||
};
|
||||
|
||||
const SLOT_REGISTRY = {};
|
||||
@@ -747,6 +748,20 @@ const DASHBOARD_STYLE = `
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
/* ── Command Palette (Ctrl+K) ────────────────────────── */
|
||||
.db-cmdk-overlay { position: fixed; inset: 0; z-index: 10002; display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); animation: fadeIn 0.15s ease; }
|
||||
.db-cmdk-box { width: 560px; max-width: 92vw; background: var(--db-sidebar-bg); border: 1px solid var(--db-border); border-radius: 12px; box-shadow: 0 16px 60px rgba(0,0,0,0.55); overflow: hidden; animation: slideIn 0.15s ease; }
|
||||
.db-cmdk-input { width: 100%; box-sizing: border-box; padding: 16px 20px; font-size: 15px; color: var(--db-text); background: transparent; border: none; border-bottom: 1px solid var(--db-border); outline: none; }
|
||||
.db-cmdk-input::placeholder { color: var(--db-text-dim); }
|
||||
.db-cmdk-list { max-height: 50vh; overflow-y: auto; padding: 6px; }
|
||||
.db-cmdk-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; color: var(--db-text); }
|
||||
.db-cmdk-item.active { background: var(--db-accent); color: #fff; }
|
||||
.db-cmdk-item.active .db-cmdk-group { color: rgba(255,255,255,0.8); }
|
||||
.db-cmdk-icon { width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.db-cmdk-title { flex: 1; }
|
||||
.db-cmdk-group { font-size: 11px; color: var(--db-text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.db-cmdk-empty { padding: 24px; text-align: center; font-size: 13px; color: var(--db-text-dim); }
|
||||
`;
|
||||
|
||||
let activePageId = null;
|
||||
@@ -909,6 +924,104 @@ function setupMobileNav(root, sidebar) {
|
||||
}
|
||||
}
|
||||
|
||||
let _paletteEl = null;
|
||||
let _paletteActive = -1;
|
||||
let _paletteResults = [];
|
||||
|
||||
function openCommandPalette() {
|
||||
if (_paletteEl) { closeCommandPalette(); return; }
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'db-cmdk-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="db-cmdk-box" role="dialog" aria-label="Command palette">
|
||||
<input class="db-cmdk-input" type="text" placeholder="Search pages and actions…" autocomplete="off" spellcheck="false">
|
||||
<div class="db-cmdk-list"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
_paletteEl = overlay;
|
||||
const input = overlay.querySelector('.db-cmdk-input');
|
||||
const listEl = overlay.querySelector('.db-cmdk-list');
|
||||
|
||||
const renderResults = (q) => {
|
||||
const query = q.trim().toLowerCase();
|
||||
const items = pagesData
|
||||
.filter((p) => p.id !== activePageId || query)
|
||||
.map((p) => ({ page: p, score: paletteScore(p, query) }))
|
||||
.filter((x) => query === '' || x.score > 0)
|
||||
.sort((a, b) => b.score - a.score || (a.page.order || 0) - (b.page.order || 0))
|
||||
.slice(0, 50)
|
||||
.map((x) => x.page);
|
||||
_paletteResults = items;
|
||||
_paletteActive = items.length ? 0 : -1;
|
||||
listEl.innerHTML = items.length
|
||||
? items
|
||||
.map(
|
||||
(p, i) =>
|
||||
`<div class="db-cmdk-item${i === 0 ? ' active' : ''}" data-idx="${i}" data-id="${p.id}"><span class="db-cmdk-icon">${p.icon || '•'}</span><span class="db-cmdk-title">${p.title}</span>${p.group ? `<span class="db-cmdk-group">${p.group}</span>` : ''}</div>`
|
||||
)
|
||||
.join('')
|
||||
: '<div class="db-cmdk-empty">No matches</div>';
|
||||
listEl.querySelectorAll('.db-cmdk-item').forEach((el) => {
|
||||
el.addEventListener('click', () => runPaletteItem(parseInt(el.dataset.idx, 10)));
|
||||
el.addEventListener('mousemove', () => setPaletteActive(parseInt(el.dataset.idx, 10)));
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => renderResults(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setPaletteActive(_paletteActive + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setPaletteActive(_paletteActive - 1); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); runPaletteItem(_paletteActive); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); closeCommandPalette(); }
|
||||
});
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeCommandPalette(); });
|
||||
renderResults('');
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function paletteScore(page, query) {
|
||||
if (!query) return 1;
|
||||
const hay = `${page.title} ${page.id} ${page.group || ''} ${page.description || ''}`.toLowerCase();
|
||||
if (hay.includes(query)) return 100 - hay.indexOf(query);
|
||||
let qi = 0;
|
||||
for (let i = 0; i < hay.length && qi < query.length; i++) {
|
||||
if (hay[i] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length ? 1 : 0;
|
||||
}
|
||||
|
||||
function setPaletteActive(idx) {
|
||||
if (!_paletteEl || !_paletteResults.length) return;
|
||||
const n = _paletteResults.length;
|
||||
_paletteActive = ((idx % n) + n) % n;
|
||||
const items = _paletteEl.querySelectorAll('.db-cmdk-item');
|
||||
items.forEach((el, i) => el.classList.toggle('active', i === _paletteActive));
|
||||
const activeEl = items[_paletteActive];
|
||||
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function runPaletteItem(idx) {
|
||||
const page = _paletteResults[idx];
|
||||
if (!page) return;
|
||||
closeCommandPalette();
|
||||
selectPage(page.id);
|
||||
}
|
||||
|
||||
function closeCommandPalette() {
|
||||
if (_paletteEl) { _paletteEl.remove(); _paletteEl = null; }
|
||||
_paletteActive = -1;
|
||||
_paletteResults = [];
|
||||
}
|
||||
|
||||
function initCommandPalette() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Inject styles
|
||||
const style = document.createElement('style');
|
||||
@@ -925,7 +1038,10 @@ async function init() {
|
||||
}
|
||||
|
||||
await buildDashboard();
|
||||
|
||||
|
||||
// Initialize Ctrl+K / Cmd+K command palette
|
||||
initCommandPalette();
|
||||
|
||||
// Initialize approval stream for modal notifications
|
||||
initApprovalStream();
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* MCP Manager View — list, connect, and disconnect Model Context Protocol servers.
|
||||
* API: GET /api/mcp/servers, POST /api/mcp/connect, POST /api/mcp/disconnect/{name}
|
||||
*/
|
||||
import { showToast, showConfirm } from '../toast.js';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
connected: 'background:rgba(34,197,94,0.15);color:#22c55e',
|
||||
connecting: 'background:rgba(234,179,8,0.15);color:#eab308',
|
||||
error: 'background:rgba(239,68,68,0.15);color:#ef4444',
|
||||
disconnected: 'background:rgba(113,113,122,0.2);color:var(--db-text-dim)',
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let servers = [];
|
||||
let loadError = null;
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers');
|
||||
const d = await r.json();
|
||||
servers = d.servers || [];
|
||||
if (d.error) loadError = d.error;
|
||||
} catch (e) {
|
||||
loadError = e.message;
|
||||
}
|
||||
|
||||
const connected = servers.filter((s) => s.status === 'connected').length;
|
||||
const totalTools = servers.reduce((n, s) => n + ((s.tools && s.tools.length) || 0), 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card"><div class="db-card-title">Servers</div><div class="db-card-value">${servers.length}</div><div class="db-card-footer">${connected} connected</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Tools</div><div class="db-card-value">${totalTools}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Health</div><div class="db-card-value">${servers.length ? Math.round((connected / servers.length) * 100) : 0}%</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<h3 style="margin:0;font-size:15px;font-weight:600">MCP Servers</h3>
|
||||
<button id="mcp-add" class="db-btn db-btn-primary">+ Connect Server</button>
|
||||
</div>
|
||||
${loadError ? `<div class="db-alert db-alert-warning"><span>⚠️</span><span>${esc(loadError)}</span></div>` : ''}
|
||||
<div id="mcp-list"></div>
|
||||
<div id="mcp-form-host"></div>
|
||||
`;
|
||||
|
||||
const list = container.querySelector('#mcp-list');
|
||||
servers.forEach((s) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:10px;padding:16px 20px';
|
||||
const isConnected = s.status === 'connected';
|
||||
const statusStyle = STATUS_STYLE[s.status] || STATUS_STYLE.disconnected;
|
||||
const tools = s.tools || [];
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
|
||||
<span style="font-size:20px">🔌</span>
|
||||
<div style="min-width:0">
|
||||
<div style="font-weight:500;font-size:14px">${esc(s.name)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(s.transport || '')} · ${tools.length} tool${tools.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${statusStyle}">${esc(s.status || 'unknown')}</span>
|
||||
${isConnected ? `<button class="mcp-disconnect db-btn db-btn-destructive" data-name="${esc(s.name)}" style="padding:4px 12px;font-size:11px">Disconnect</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${s.last_error ? `<div style="font-size:12px;color:#ef4444;margin-top:8px">${esc(s.last_error)}</div>` : ''}
|
||||
${tools.length ? `<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--db-text-dim)">Tools (${tools.length})</summary><div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">${tools
|
||||
.map(
|
||||
(t) =>
|
||||
`<div style="font-size:12px"><code style="color:var(--db-text)">${esc(t.name)}</code>${t.description ? ` <span style="color:var(--db-text-dim)">— ${esc(t.description)}</span>` : ''}</div>`
|
||||
)
|
||||
.join('')}</div></details>` : ''}
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
if (servers.length === 0 && !loadError) {
|
||||
list.innerHTML = '<div class="db-viewer"><pre>No MCP servers connected. Click "Connect Server" to add one.</pre></div>';
|
||||
}
|
||||
|
||||
container.querySelectorAll('.mcp-disconnect').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
const name = b.dataset.name;
|
||||
if (!(await showConfirm('Disconnect MCP Server', `Disconnect "${name}"?`))) return;
|
||||
try {
|
||||
const r = await fetch(`/api/mcp/disconnect/${encodeURIComponent(name)}`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok && d.success) {
|
||||
showToast(`Disconnected from ${name}`, 'success');
|
||||
render(container);
|
||||
} else {
|
||||
showToast(d.error || 'Disconnect failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const addBtn = container.querySelector('#mcp-add');
|
||||
const formHost = container.querySelector('#mcp-form-host');
|
||||
addBtn.addEventListener('click', () => openConnectForm(formHost, container));
|
||||
}
|
||||
|
||||
function openConnectForm(host, container) {
|
||||
host.innerHTML = `
|
||||
<div class="db-card" style="margin-top:16px;padding:20px">
|
||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600">Connect MCP Server</h3>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Name</label>
|
||||
<input id="mcp-f-name" class="db-form-input" placeholder="my-server">
|
||||
</div>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Transport</label>
|
||||
<select id="mcp-f-transport" class="db-form-select">
|
||||
<option value="stdio">stdio (command)</option>
|
||||
<option value="sse">SSE / HTTP (url)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="mcp-f-stdio">
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Command</label>
|
||||
<input id="mcp-f-command" class="db-form-input" placeholder="npx">
|
||||
</div>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Arguments (space-separated)</label>
|
||||
<input id="mcp-f-args" class="db-form-input" placeholder="-y @modelcontextprotocol/server-filesystem /tmp">
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-f-url" style="display:none">
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">URL</label>
|
||||
<input id="mcp-f-urlval" class="db-form-input" placeholder="https://example.com/sse">
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-button-group" style="margin:0">
|
||||
<button id="mcp-f-submit" class="db-btn db-btn-primary">Connect</button>
|
||||
<button id="mcp-f-cancel" class="db-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const transportSel = host.querySelector('#mcp-f-transport');
|
||||
const stdioBlock = host.querySelector('#mcp-f-stdio');
|
||||
const urlBlock = host.querySelector('#mcp-f-url');
|
||||
transportSel.addEventListener('change', () => {
|
||||
const isStdio = transportSel.value === 'stdio';
|
||||
stdioBlock.style.display = isStdio ? '' : 'none';
|
||||
urlBlock.style.display = isStdio ? 'none' : '';
|
||||
});
|
||||
|
||||
host.querySelector('#mcp-f-cancel').addEventListener('click', () => {
|
||||
host.innerHTML = '';
|
||||
});
|
||||
|
||||
host.querySelector('#mcp-f-submit').addEventListener('click', async () => {
|
||||
const name = host.querySelector('#mcp-f-name').value.trim();
|
||||
if (!name) {
|
||||
showToast('Name is required', 'error');
|
||||
return;
|
||||
}
|
||||
const body = { name };
|
||||
if (transportSel.value === 'stdio') {
|
||||
const command = host.querySelector('#mcp-f-command').value.trim();
|
||||
if (!command) {
|
||||
showToast('Command is required for stdio', 'error');
|
||||
return;
|
||||
}
|
||||
body.command = command;
|
||||
const argsRaw = host.querySelector('#mcp-f-args').value.trim();
|
||||
body.args = argsRaw ? argsRaw.split(/\s+/) : [];
|
||||
} else {
|
||||
const url = host.querySelector('#mcp-f-urlval').value.trim();
|
||||
if (!url) {
|
||||
showToast('URL is required', 'error');
|
||||
return;
|
||||
}
|
||||
body.url = url;
|
||||
}
|
||||
|
||||
const submitBtn = host.querySelector('#mcp-f-submit');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Connecting…';
|
||||
try {
|
||||
const r = await fetch('/api/mcp/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok && d.server) {
|
||||
showToast(`Connected to ${d.server.name}`, 'success');
|
||||
host.innerHTML = '';
|
||||
render(container);
|
||||
} else {
|
||||
showToast(d.error || d.hint || 'Connection failed', 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Connect';
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Connect';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -734,6 +734,19 @@ class TestPagesAPI:
|
||||
if ev:
|
||||
assert ev["group"] == "Control"
|
||||
|
||||
def test_mcp_page_registered(self, client):
|
||||
r = client.get("/api/pages")
|
||||
pages = r.json().get("pages", r.json())
|
||||
mcp = next((p for p in pages if p["id"] == "mcp"), None)
|
||||
assert mcp is not None, "MCP page should be registered"
|
||||
assert mcp["group"] == "Agent"
|
||||
assert mcp["api_endpoint"] == "/api/mcp/servers"
|
||||
|
||||
def test_mcp_servers_endpoint(self, client):
|
||||
r = client.get("/api/mcp/servers")
|
||||
assert r.status_code == 200
|
||||
assert "servers" in r.json()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Persistence across restart
|
||||
|
||||
Reference in new issue
Block a user