feat: workbench MCP server — AI-driven hardware diagnostic tool
MCP server (Python/aiohttp) that lets any AI CLI spin up interactive hardware diagnostic web pages served over LAN with WebSocket live updates and dual-format session logging (markdown + JSONL). 6 tools: scaffold, state, log, read_log, list, stop Split-pane scaffold: diagnostic content + sethmux terminal iframe CLI wrapper: ~/bin/workbench (serve, list, mcp, help) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0f0c;
|
||||
--panel-bg: #111a15;
|
||||
--border: #2a3a2e;
|
||||
--text: #aaccaa;
|
||||
--text-dim: #557755;
|
||||
--accent: #33ff66;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font-family: monospace; font-size: 14px; height: 100vh; overflow: hidden; }
|
||||
|
||||
.layout { display: flex; height: 100vh; }
|
||||
.diag-panel { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.divider { width: 4px; background: var(--border); cursor: col-resize; }
|
||||
.term-panel { width: 40%; min-width: 300px; }
|
||||
.term-panel iframe { width: 100%; height: 100%; border: none; }
|
||||
|
||||
/* Mobile: stack vertically with tabs */
|
||||
@media (max-width: 768px) {
|
||||
.layout { flex-direction: column; }
|
||||
.divider { display: none; }
|
||||
.term-panel { width: 100%; height: 50vh; }
|
||||
.diag-panel { height: 50vh; }
|
||||
}
|
||||
|
||||
/* Log feed at bottom of diag panel */
|
||||
#log-feed { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
#log-feed h3 { color: var(--text-dim); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.log-entry { font-size: 12px; color: var(--text-dim); padding: 4px 0; border-bottom: 1px solid #1a2a1e; }
|
||||
.log-entry .log-time { color: var(--accent); margin-right: 8px; }
|
||||
|
||||
/* Status bar */
|
||||
#status { position: fixed; bottom: 0; left: 0; right: 0; background: var(--panel-bg); border-top: 1px solid var(--border); padding: 4px 12px; font-size: 11px; color: var(--text-dim); z-index: 100; }
|
||||
#status.connected { color: var(--accent); }
|
||||
|
||||
/* Content area where AI pushes diagnostic UI */
|
||||
#content { min-height: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<div class="diag-panel">
|
||||
<div id="content">
|
||||
<h1 style="color: var(--accent); font-size: 18px;">{{TITLE}}</h1>
|
||||
<p style="color: var(--text-dim); margin-top: 8px;">{{DESCRIPTION}}</p>
|
||||
<p style="color: var(--text-dim); margin-top: 16px;">Waiting for diagnostic content...</p>
|
||||
</div>
|
||||
<div id="log-feed">
|
||||
<h3>Session Log</h3>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider" id="divider"></div>
|
||||
<div class="term-panel">
|
||||
<iframe src="{{SETHMUX_URL}}" allow="clipboard-read; clipboard-write"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status">Connecting...</div>
|
||||
|
||||
<script>
|
||||
const WS_URL = `ws://${location.host}/ws`;
|
||||
let ws, reconnectTimer;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws.onopen = () => {
|
||||
document.getElementById('status').textContent = 'Connected';
|
||||
document.getElementById('status').className = 'connected';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
document.getElementById('status').textContent = 'Disconnected — reconnecting...';
|
||||
document.getElementById('status').className = '';
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'state') handleState(msg.state);
|
||||
if (msg.type === 'log') handleLog(msg.entry);
|
||||
};
|
||||
}
|
||||
|
||||
function handleState(state) {
|
||||
// If state has a 'template' field, replace the content area HTML
|
||||
if (state.template) {
|
||||
document.getElementById('content').innerHTML = state.template;
|
||||
}
|
||||
// If state has a 'styles' field, inject/replace a style block
|
||||
if (state.styles) {
|
||||
let el = document.getElementById('dynamic-styles');
|
||||
if (!el) { el = document.createElement('style'); el.id = 'dynamic-styles'; document.head.appendChild(el); }
|
||||
el.textContent = state.styles;
|
||||
}
|
||||
// If state has a 'script' field, execute it
|
||||
if (state.script) {
|
||||
try { new Function(state.script)(); } catch(e) { console.error('Script error:', e); }
|
||||
}
|
||||
// Store full state for AI-generated scripts to access
|
||||
window.__workbench_state = state;
|
||||
// Save to localStorage as fallback
|
||||
try { localStorage.setItem('workbench-state', JSON.stringify(state)); } catch(e) {}
|
||||
}
|
||||
|
||||
function handleLog(entry) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
const time = new Date().toLocaleTimeString();
|
||||
div.innerHTML = `<span class="log-time">${time}</span>${entry}`;
|
||||
const feed = document.getElementById('log-entries');
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
// Restore state from localStorage on load
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
||||
if (saved) handleState(saved);
|
||||
} catch(e) {}
|
||||
|
||||
// Draggable divider
|
||||
const divider = document.getElementById('divider');
|
||||
let dragging = false;
|
||||
divider.addEventListener('mousedown', () => dragging = true);
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
const pct = (e.clientX / window.innerWidth) * 100;
|
||||
document.querySelector('.diag-panel').style.flex = 'none';
|
||||
document.querySelector('.diag-panel').style.width = pct + '%';
|
||||
document.querySelector('.term-panel').style.width = (100 - pct) + '%';
|
||||
});
|
||||
document.addEventListener('mouseup', () => dragging = false);
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user