message / plugins /browser /frontend /index.html
hunian
refactor(plugins): 插件短名并统一 MCP tool 为 {plugin}-{tool}
cc826a1
Raw
History Blame Contribute Delete
11.5 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览器调试台</title>
<link rel="stylesheet" href="/static/plugin-ui.css">
<style>
.binary-card { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
.binary-card.ok { border-color: var(--success, #16a34a); }
.binary-card.fail { border-color: var(--error, #dc2626); }
.binary-path { font-family: monospace; font-size: 13px; color: var(--text-muted); word-break: break-all; }
.install-hint { background: var(--bg-muted); padding: 8px; border-radius: 4px; font-family: monospace; font-size: 12px; margin-top: 8px; white-space: pre-wrap; word-break: break-all; }
.tool-desc { font-size: 13px; color: var(--text-muted); margin: 8px 0; }
.param-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.param-row label { min-width: 120px; font-size: 13px; }
.param-row input { flex: 1; }
.param-required { color: var(--error, #dc2626); margin-left: 2px; }
.health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; font-size: 13px; }
.health-item { background: var(--bg-muted); padding: 8px; border-radius: 4px; }
.health-value { font-weight: 600; }
.health-value.ok { color: var(--success, #16a34a); }
.health-value.fail { color: var(--error, #dc2626); }
.drift-warn { color: var(--error, #dc2626); font-size: 12px; margin-top: 6px; }
pre.result { white-space: pre-wrap; word-break: break-word; max-height: 500px; overflow: auto; }
</style>
</head>
<body>
<main class="plugin-shell">
<header class="plugin-header">
<div>
<h1 class="plugin-title">浏览器调试台</h1>
<p class="plugin-subtitle">纯 Lightpanda 单后端,27 个通用浏览器 MCP 工具透传。执行 goto/markdown/click/fill/extract 等,实时查看运行日志。</p>
</div>
<span id="statusPill" class="plugin-status">检查中</span>
</header>
<div id="message" class="plugin-message"></div>
<!-- Lightpanda 二进制状态 -->
<section id="binaryCard" class="binary-card">检测中...</section>
<!-- 健康检查 -->
<section class="plugin-panel" style="margin-bottom: 12px;">
<h2 class="plugin-panel-title">健康检查</h2>
<div id="healthContent">加载中...</div>
</section>
<!-- 主网格:左工具调用 | 右运行日志 -->
<section class="plugin-grid">
<div class="plugin-panel">
<h2 class="plugin-panel-title">工具调用</h2>
<div class="plugin-stack">
<div class="plugin-field">
<label class="plugin-label" for="toolSelect">工具</label>
<select id="toolSelect" class="plugin-input"></select>
</div>
<div id="toolDesc" class="tool-desc"></div>
<div id="toolForm" class="plugin-stack"></div>
<div class="plugin-row">
<button id="runBtn" class="plugin-button" onclick="runTool()">执行</button>
<button class="plugin-button secondary" onclick="loadAll()">刷新</button>
</div>
</div>
</div>
<div class="plugin-panel">
<h2 class="plugin-panel-title">运行日志</h2>
<pre id="logPanel" class="plugin-output" style="max-height: 400px; overflow: auto;"></pre>
</div>
</section>
<!-- 结果区 -->
<section id="resultSection" class="plugin-panel" style="margin-top: 12px; display: none;">
<h2 class="plugin-panel-title">结果</h2>
<div id="resultMeta" class="tool-desc"></div>
<pre id="resultContent" class="result plugin-output"></pre>
</section>
<!-- 子进程日志 -->
<section class="plugin-panel" style="margin-top: 12px;">
<h2 class="plugin-panel-title">子进程 stderr 日志</h2>
<div class="plugin-row" style="margin-bottom: 8px;">
<button class="plugin-button secondary" onclick="loadProcessLogs()">刷新日志</button>
<button class="plugin-button secondary" onclick="stopProcess()">停止子进程</button>
</div>
<pre id="processLogs" class="plugin-output" style="max-height: 300px; overflow: auto;"></pre>
</section>
</main>
<script>
const API = '/plugins/browser/api';
let TOOLS = [];
let currentTool = null;
function msg(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = 'plugin-message' + (type ? ' ' + type : '');
}
async function loadBinary() {
try {
const r = await fetch(`${API}/binary-check`);
const d = await r.json();
const card = document.getElementById('binaryCard');
if (d.available) {
card.className = 'binary-card ok';
card.innerHTML = `<strong>Lightpanda 二进制:可用</strong>
<div class="binary-path">${d.path}</div>
<div class="binary-path">版本:${d.version || '未知'} | 来源:${d.source} | 平台:${d.platform}</div>`;
document.getElementById('statusPill').textContent = '就绪';
} else {
card.className = 'binary-card fail';
const hint = d.install_hints || {};
const cmd = hint[d.platform] || hint.command || hint.linux || '';
card.innerHTML = `<strong>Lightpanda 二进制:未找到</strong>
<div class="tool-desc">${d.error || ''}</div>
<div class="install-hint">${cmd}</div>`;
document.getElementById('statusPill').textContent = '二进制缺失';
}
} catch (e) { msg('二进制检测失败: ' + e, 'error'); }
}
async function loadHealth() {
try {
const r = await fetch(`${API}/health`);
const d = await r.json();
const c = document.getElementById('healthContent');
const binCls = d.binary_available ? 'ok' : 'fail';
const procCls = d.process_alive ? 'ok' : 'fail';
let html = `<div class="health-grid">
<div class="health-item">二进制<span class="health-value ${binCls}">${d.binary_available ? '可用' : '缺失'}</span></div>
<div class="health-item">子进程<span class="health-value ${procCls}">${d.process_alive ? '运行中' : '未启动'}</span></div>
<div class="health-item">工具缓存<span class="health-value">${d.tools_count}</span></div>
<div class="health-item">版本<span class="health-value">${d.binary_version || '-'}</span></div>
</div>`;
if (d.drift) {
html += `<div class="drift-warn">工具集漂移:上游缺少 [${d.drift.missing_in_upstream.join(', ')}],上游多出 [${d.drift.extra_in_upstream.join(', ')}]</div>`;
}
c.innerHTML = html;
} catch (e) { document.getElementById('healthContent').textContent = '健康检查失败: ' + e; }
}
async function loadTools() {
try {
const r = await fetch(`${API}/tools`);
const d = await r.json();
TOOLS = d.tools || [];
const sel = document.getElementById('toolSelect');
sel.innerHTML = TOOLS.map(t => `<option value="${t.name}">${t.title}${t.name})</option>`).join('');
if (TOOLS.length) selectTool(TOOLS[0].name);
} catch (e) { msg('工具列表加载失败: ' + e, 'error'); }
}
function selectTool(name) {
currentTool = TOOLS.find(t => t.name === name);
if (!currentTool) return;
document.getElementById('toolDesc').textContent = currentTool.summary + ' — ' + (currentTool.read_only ? '只读' : '有副作用');
const props = (currentTool.input_schema && currentTool.input_schema.properties) || {};
const required = (currentTool.input_schema && currentTool.input_schema.required) || [];
const form = document.getElementById('toolForm');
const keys = Object.keys(props);
if (!keys.length) {
form.innerHTML = '<div class="tool-desc">该工具无入参。</div>';
return;
}
form.innerHTML = keys.map(k => {
const p = props[k];
const req = required.includes(k) ? '<span class="param-required">*</span>' : '';
const ph = p.description ? p.description.slice(0, 60) : '';
return `<div class="param-row"><label>${k}${req}</label>
<input class="plugin-input" data-key="${k}" placeholder="${ph}" /></div>`;
}).join('');
}
async function runTool() {
if (!currentTool) return;
const inputs = document.querySelectorAll('#toolForm input[data-key]');
const params = {};
inputs.forEach(inp => {
const k = inp.dataset.key;
const v = inp.value;
if (v === '') return;
// 尝试解析 JSON(数字/布尔/对象),失败则保留字符串
try { params[k] = JSON.parse(v); } catch { params[k] = v; }
});
document.getElementById('runBtn').disabled = true;
msg('执行中: ' + currentTool.name, '');
document.getElementById('logPanel').textContent = '...';
try {
const r = await fetch(`${API}/mcp/${currentTool.name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const d = await r.json();
showResult(d);
if (d.run_id) pollLog(d.run_id);
} catch (e) {
msg('执行失败: ' + e, 'error');
} finally {
document.getElementById('runBtn').disabled = false;
}
}
function showResult(d) {
const sec = document.getElementById('resultSection');
sec.style.display = 'block';
const meta = document.getElementById('resultMeta');
const content = document.getElementById('resultContent');
if (d.success) {
meta.textContent = `✓ ${d.tool} 成功(run_id: ${d.run_id})`;
content.textContent = d.result || '(空结果)';
} else {
meta.textContent = `✗ ${d.tool || ''} 失败 [${d.error_code || ''}](run_id: ${d.run_id || '-'})`;
content.textContent = d.error || '';
}
}
async function pollLog(runId) {
// run_log 事件由平台 run 服务存储;这里用插件 run_log 通用端点(若平台提供)
// 兜底:直接展示子进程日志尾部
try {
const r = await fetch(`${API}/process/logs?tail=30`);
const d = await r.json();
document.getElementById('logPanel').textContent = (d.lines || []).join('\n') || '(无日志)';
} catch { document.getElementById('logPanel').textContent = '(日志不可用)'; }
}
async function loadProcessLogs() {
try {
const r = await fetch(`${API}/process/logs?tail=100`);
const d = await r.json();
document.getElementById('processLogs').textContent = (d.lines || []).join('\n') || '(无日志)';
} catch (e) { document.getElementById('processLogs').textContent = '加载失败: ' + e; }
}
async function stopProcess() {
try {
await fetch(`${API}/process/stop`, { method: 'POST' });
msg('子进程已停止', '');
loadHealth();
} catch (e) { msg('停止失败: ' + e, 'error'); }
}
async function loadAll() {
await Promise.all([loadBinary(), loadHealth(), loadTools(), loadProcessLogs()]);
}
document.getElementById('toolSelect').addEventListener('change', e => selectTool(e.target.value));
loadAll();
</script>
</body>
</html>