| <!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> |
|
|
| |
| <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; |
| |
| 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) { |
| |
| |
| 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> |
|
|