Files

493 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%#
luci-app-openclaw — Web 控制台页面
打开 OpenClaw 官方 Web UI,用户可在此配置模型/渠道并直接对话
-%>
<%+header%>
<%
local uci = require "luci.model.uci".cursor()
local console_utils = require "openclaw.console"
local port = uci:get("openclaw", "main", "port") or "18789"
local raw_console_url = tostring(uci:get("openclaw", "main", "console_url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local https_console_url = console_utils.normalize_https_url(raw_console_url) or ""
%>
<style type="text/css">
.oc-page-header { margin: 0 0 16px 0; }
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: inherit; margin: 0 0 6px 0; }
.oc-page-header p { font-size: 13px; color: inherit; opacity: 0.76; margin: 0; line-height: 1.6; }
.oc-console-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 16px;
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
font-size: 13px;
color: #555;
flex-wrap: wrap;
}
.oc-console-info .label { color: #888; }
.oc-console-info .value { font-family: monospace; color: #333; font-weight: 500; }
.oc-console-info .sep { color: #ddd; }
.oc-console-actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.oc-console-actions .btn-action {
padding: 4px 12px;
background: #f0f2f5;
color: #333;
border: 1px solid #d0d7de;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.oc-console-actions .btn-action:hover {
background: #e1e4e8;
text-decoration: none;
}
.oc-console-actions .btn-open {
padding: 4px 14px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.oc-console-actions .btn-open:hover {
background: #357abd;
text-decoration: none;
}
.oc-console-wrap {
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
position: relative;
}
/* OpenClaw Control UI 固定返回 X-Frame-Options: DENY 与 CSP frame-ancestors 'none',
浏览器禁止任何页面内嵌它,这里改为在独立窗口打开 (#108) */
.oc-console-launcher {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 40px 20px; text-align: center; gap: 12px;
}
.oc-console-launcher .oc-launch-title { font-size: 16px; font-weight: 600; }
.oc-console-launcher .oc-launch-desc { font-size: 13px; opacity: 0.8; line-height: 1.7; max-width: 640px; }
.oc-console-launcher .oc-launch-btn {
display: inline-block; padding: 10px 28px; background: #4a90d9; color: #fff;
border-radius: 6px; font-size: 15px; font-weight: 600; text-decoration: none;
}
.oc-console-launcher .oc-launch-btn:hover { background: #357abd; text-decoration: none; }
.oc-console-loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 360px; color: #666; font-size: 14px; background: #fafafa;
}
.oc-console-loading .spinner {
width: 32px; height: 32px; border: 3px solid #e0e0e0; border-top: 3px solid #4a90d9;
border-radius: 50%; animation: oc-spin .8s linear infinite; margin-bottom: 12px;
}
@keyframes oc-spin { to { transform: rotate(360deg); } }
</style>
<div class="oc-page-header">
<h2>🖥️ Web 控制台</h2>
<p>OpenClaw 官方 Web 管理界面 — 在这里可以配置 AI 模型、消息渠道(QQ、Telegram、Discord 等),直接与 AI 进行对话,以及管理所有功能。</p>
</div>
<div class="oc-console-info">
<span class="label">网关地址:</span>
<span class="value" id="oc-console-addr">-</span>
<span class="sep">|</span>
<span class="label">活跃模型:</span>
<span class="value" id="oc-console-model" style="color:#555;">-</span>
<span class="sep">|</span>
<span class="label">状态:</span>
<span id="oc-console-status-text">检查中...</span>
<div class="oc-console-actions" id="oc-console-actions" style="display:none;">
<button type="button" id="oc-console-pairing-toggle-btn" class="btn-action" title="管理/批准设备配对请求">📱 设备配对 <span id="oc-console-pairing-nav-badge" style="display:none; background:#ff4d4f; color:#fff; padding:0 6px; border-radius:10px; font-size:11px; font-weight:600; margin-left:2px;">0</span></button>
<a id="oc-console-open-btn" class="btn-open" href="#" target="_blank" rel="noopener noreferrer" title="新窗口打开">
↗ 新窗口直达
</a>
</div>
</div>
<div id="oc-pairing-banner" style="display:none; padding:12px 16px; margin-bottom:14px; background:#fffbe6; border:1px solid #ffe58f; border-left:4px solid #faad14; border-radius:6px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:280px;">
<div style="font-weight:600; color:#d48806; font-size:14px; margin-bottom:4px; display:flex; align-items:center; gap:8px;">
<span>🔔 检测到待配对设备请求</span>
<span id="oc-pairing-badge" style="background:#faad14; color:#fff; padding:1px 7px; border-radius:10px; font-size:11px;">0</span>
</div>
<div style="font-size:13px; color:#555; line-height:1.5; margin-bottom:6px;">
首次访问 Control UI 时网关需要主机批准该浏览器。点击「一键批准配对」即可完成授权并进入控制台。
</div>
<div style="font-size:12px; color:#cf1322; background:#fff1f0; border:1px solid #ffa39e; padding:6px 10px; border-radius:4px; line-height:1.4; margin-bottom:6px;">
⚠️ <strong>风险提示</strong>:批准设备配对后,该浏览器将获得 OpenClaw 网关的完全控制权限。请确保在受信任的局域网环境,并仅在您本人正在连接时批准。
</div>
<div id="oc-pairing-items" style="font-size:12px; font-family:monospace; color:#333; line-height:1.6;"></div>
</div>
<div style="display:flex; gap:8px; align-items:center; margin-top:4px;">
<button type="button" id="oc-pairing-approve-all-btn" class="btn-open" style="background:#52c41a; font-weight:600; padding:6px 14px;">
⚡ 一键批准配对
</button>
<button type="button" id="oc-pairing-refresh-btn" class="btn-action" style="padding:6px 10px;">
🔄 刷新
</button>
</div>
</div>
<div id="oc-pairing-msg" style="display:none; margin-top:8px; font-size:13px; font-weight:500;"></div>
</div>
<div id="oc-console-https-hint" style="display:none; padding:10px 14px; margin-bottom:14px; background:#fffbe6; border:1px solid #ffe58f; border-radius:6px; font-size:13px; color:#d48806; line-height:1.6;">
⚠️ <strong>浏览器安全提示</strong>:当前正在通过 HTTPS 访问 LuCI,而 OpenClaw 网关默认提供 HTTP 服务。在新窗口打开 HTTP 控制台时浏览器可能提示「不安全」,如需全程 HTTPS,请在基本设置中配置 HTTPS 控制台地址(反向代理)。
</div>
<div id="oc-console-context-hint" style="display:none; padding:10px 14px; margin-bottom:14px; background:#e6f4ff; border:1px solid #91caff; border-left:4px solid #1677ff; border-radius:6px; font-size:13px; color:#0958d9; line-height:1.6;">
<div style="font-weight:600; margin-bottom:4px; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<span>💡 访问条件提示(图片/附件与安全上下文)</span>
<a id="oc-console-direct-btn" href="#" target="_blank" rel="noopener noreferrer" class="btn-action" style="background:#fff; color:#0958d9; border-color:#91caff; font-weight:normal; text-decoration:none; display:inline-flex; align-items:center; gap:4px;">
↗ 打开控制台
</a>
</div>
<div id="oc-console-context-detail" style="font-size:12px; color:#333;">
HTTP 局域网地址不属于浏览器安全上下文,图片粘贴、附件拖拽、麦克风和剪贴板能力可能不可用。<br/>
<strong>完整附件功能请通过 SSH localhost 隧道访问顶级页面:</strong><br/>
<code id="oc-console-tunnel-command"></code><br/>
然后打开 <code id="oc-console-tunnel-url">http://localhost:18790/</code>。若本地 18790 端口被占用,请将命令和地址中的 18790 一并改为空闲端口。<br/>
配置了可信 HTTPS 控制台地址时,「打开控制台」仅打开该地址,不会自动传递 Gateway Token。
</div>
</div>
<span id="oc-console-config" data-console-url="<%=console_utils.html_attr(https_console_url)%>" style="display:none;"></span>
<div class="oc-console-wrap" id="oc-console-wrap">
<div id="oc-console-container">
<div class="oc-console-loading" id="oc-console-loading">
<div class="spinner"></div>
<span>正在连接 OpenClaw 控制台...</span>
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var gwPort = '<%=port%>';
var consoleConfigEl = document.getElementById('oc-console-config');
var uciHttpsConsoleUrl = consoleConfigEl ? (consoleConfigEl.getAttribute('data-console-url') || '') : '';
var gwToken = '';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
var ocCsrfToken = '<%=token%>';
var container = document.getElementById('oc-console-container');
var loading = document.getElementById('oc-console-loading');
var addrEl = document.getElementById('oc-console-addr');
var modelEl = document.getElementById('oc-console-model');
var statusTextEl = document.getElementById('oc-console-status-text');
var actionsEl = document.getElementById('oc-console-actions');
var openBtn = document.getElementById('oc-console-open-btn');
var launcherEl = null;
var tunnelCommandEl = document.getElementById('oc-console-tunnel-command');
if (tunnelCommandEl) tunnelCommandEl.textContent = 'ssh -N -L 127.0.0.1:18790:127.0.0.1:' + gwPort + ' root@' + window.location.hostname;
function getGatewayBaseUrl() {
var host = window.location.hostname;
return 'http://' + host + ':' + gwPort + '/';
}
function appendGatewayToken(url) {
if (gwToken) {
url += (url.indexOf('#') === -1 ? '#' : '&') + 'token=' + encodeURIComponent(gwToken);
}
return url;
}
function getExternalConsoleUrl() {
if (uciHttpsConsoleUrl && uciHttpsConsoleUrl.indexOf('https://') === 0) return uciHttpsConsoleUrl;
return appendGatewayToken(getGatewayBaseUrl());
}
function updateContextHint() {
var hintEl = document.getElementById('oc-console-context-hint');
var directBtn = document.getElementById('oc-console-direct-btn');
if (directBtn) directBtn.href = getExternalConsoleUrl();
if (hintEl) hintEl.style.display = getGatewayBaseUrl().indexOf('http://localhost') === 0 || getGatewayBaseUrl().indexOf('http://127.0.0.1') === 0 ? 'none' : 'block';
}
var retryTimer = null;
function scheduleCheck(ms) {
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(function() {
retryTimer = null;
checkAndLoad();
}, ms || 3000);
}
function checkAndLoad() {
if (uciHttpsConsoleUrl && uciHttpsConsoleUrl.indexOf('https://') === 0) {
addrEl.textContent = uciHttpsConsoleUrl;
} else {
addrEl.textContent = window.location.hostname + ':' + gwPort;
}
(new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
try {
var td = JSON.parse(tx.responseText);
gwToken = td.token || '';
} catch(e) {}
(new XHR()).get(statusUrl, null, function(x) {
try {
var d = JSON.parse(x.responseText);
var externalUrl = getExternalConsoleUrl();
if (d.active_model) {
modelEl.textContent = d.active_model;
}
if (d.gateway_running) {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
statusTextEl.innerHTML = '<span style="color:#1a7f37;">● 网关运行中</span>';
openBtn.href = externalUrl;
actionsEl.style.display = 'flex';
updateContextHint();
if (location.protocol === 'https:' && externalUrl.indexOf('https://') !== 0) {
var hintEl = document.getElementById('oc-console-https-hint');
if (hintEl) hintEl.style.display = 'block';
}
showLauncher(externalUrl);
checkDevices(false);
setTimeout(function() { checkDevices(false); }, 2500);
setTimeout(function() { checkDevices(false); }, 6000);
} else if (d.gateway_failed) {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
statusTextEl.innerHTML = '<span style="color:#cf222e;">● 网关启动失败</span>';
actionsEl.style.display = 'none';
loading.style.display = '';
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
'<div style="font-size:40px;margin-bottom:12px;">×</div>' +
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关启动失败</div>' +
'<div style="font-size:12px;color:#999;">退出码:' + (d.gateway_exit_code || '-') + '。请查看系统日志或安装日志。</div>' +
'</div>';
} else if (d.gateway_starting) {
statusTextEl.innerHTML = '<span style="color:#9a6700;">⏳ 网关正在启动</span>';
actionsEl.style.display = 'none';
loading.style.display = '';
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
'<div style="font-size:40px;margin-bottom:12px;">⏳</div>' +
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关正在启动中...</div>' +
'<div style="font-size:12px;color:#999;">通常 20~40 秒;首次安装或路由器负载较高时会更久,页面会自动刷新。</div>' +
'</div>';
scheduleCheck(3000);
} else {
statusTextEl.innerHTML = '<span style="color:#cf222e;">● 网关未运行</span>';
actionsEl.style.display = 'none';
loading.style.display = '';
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
'<div style="font-size:40px;margin-bottom:12px;">🧠</div>' +
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关未运行</div>' +
'<div style="font-size:12px;color:#999;">请先在「基本设置」页面启用服务并启动。</div>' +
'</div>';
scheduleCheck(5000);
}
} catch(e) {
statusTextEl.textContent = '查询失败';
scheduleCheck(5000);
}
});
});
}
// OpenClaw Control UI 对所有响应固定设置 X-Frame-Options: DENY 与 frame-ancestors 'none'
// (上游无配置项可关闭),任何 iframe 内嵌都会被浏览器拒绝并显示「拒绝连接」(#108)。
// 因此只提供顶级窗口入口;顶级页面同时也是图片/附件等安全上下文能力的前提。
function showLauncher(url) {
loading.style.display = 'none';
if (!launcherEl) {
launcherEl = document.createElement('div');
launcherEl.className = 'oc-console-launcher';
launcherEl.innerHTML =
'<div style="font-size:40px;">🦞</div>' +
'<div class="oc-launch-title">OpenClaw 控制台已就绪</div>' +
'<a id="oc-console-launch-btn" class="oc-launch-btn" href="#" target="_blank" rel="noopener noreferrer">↗ 在新窗口打开控制台</a>' +
'<div class="oc-launch-desc">OpenClaw 官方控制台出于安全策略禁止被嵌入其它页面(X-Frame-Options: DENY),' +
'因此需要在独立窗口中使用。首次打开若提示「需要设备配对」,回到本页点击上方「📱 设备配对」批准后,在控制台窗口刷新即可。</div>';
container.appendChild(launcherEl);
}
var launchBtn = document.getElementById('oc-console-launch-btn');
if (launchBtn) launchBtn.href = url;
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
var devicesListUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "devices_list")%>';
var devicesApproveUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "devices_approve")%>';
var pairingBanner = document.getElementById('oc-pairing-banner');
var pairingBadge = document.getElementById('oc-pairing-badge');
var pairingItems = document.getElementById('oc-pairing-items');
var pairingMsg = document.getElementById('oc-pairing-msg');
var pairingApproveAllBtn = document.getElementById('oc-pairing-approve-all-btn');
var pairingRefreshBtn = document.getElementById('oc-pairing-refresh-btn');
var pairingToggleBtn = document.getElementById('oc-console-pairing-toggle-btn');
function showPairingItemsMessage(message, color) {
pairingItems.textContent = '';
var line = document.createElement('div');
line.style.color = color || '#cf222e';
line.style.fontSize = '12px';
line.textContent = message;
pairingItems.appendChild(line);
}
function checkDevices(manualOpen) {
(new XHR()).get(devicesListUrl, null, function(x) {
try {
var data = JSON.parse(x.responseText);
if (!data || data.status !== 'ok') {
if (pairingBadge) pairingBadge.textContent = '!';
var errMsg = (data && data.message) || '查询设备列表失败';
if (manualOpen) {
showPairingItemsMessage('❌ ' + errMsg);
pairingBanner.style.display = 'block';
}
return;
}
if (!Array.isArray(data.pending) || !Array.isArray(data.paired)) throw new Error('设备列表结构无效');
var pending = data.pending;
if (pairingBadge) pairingBadge.textContent = pending.length;
var navBadge = document.getElementById('oc-console-pairing-nav-badge');
if (navBadge) {
if (pending.length > 0) {
navBadge.textContent = pending.length;
navBadge.style.display = 'inline';
} else {
navBadge.style.display = 'none';
}
}
if (pending.length > 0) {
var html = '';
for (var i = 0; i < pending.length; i++) {
var item = pending[i];
var ip = escapeHtml(item.remoteIp || item.ip || '未知IP');
var client = escapeHtml(item.clientId || item.clientMode || 'webchat');
var plat = escapeHtml(item.platform || '');
var rid = escapeHtml(item.requestId || '');
html += '<div style="background:#fff; border:1px solid #e1e4e8; border-radius:4px; padding:6px 10px; margin-bottom:6px; display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">' +
'<div><strong>IP:</strong> ' + ip + ' | <strong>客户端:</strong> ' + client + (plat ? ' (' + plat + ')' : '') + ' <br/><span style="color:#888; font-size:11px;">ID: ' + rid + '</span></div>' +
'<button type="button" class="btn-action btn-approve-single" data-rid="' + rid + '" style="font-size:12px; padding:3px 10px; background:#f6ffed; border-color:#b7eb8f; color:#389e0d; cursor:pointer;">批准配对</button>' +
'</div>';
}
pairingItems.innerHTML = html;
pairingBanner.style.display = 'block';
var singleBtns = pairingItems.querySelectorAll('.btn-approve-single');
for (var j = 0; j < singleBtns.length; j++) {
singleBtns[j].addEventListener('click', function(ev) {
var rid = this.getAttribute('data-rid');
approveDevice(rid);
});
}
} else {
pairingItems.innerHTML = '<div style="color:#666; font-size:12px; padding:4px 0;">当前无待配对设备请求。当浏览器连接控制台提示「需要设备配对」时,在此刷新即可显示并批准。</div>';
if (manualOpen) {
pairingBanner.style.display = 'block';
} else {
pairingBanner.style.display = 'none';
}
}
} catch(e) {
if (manualOpen) {
showPairingItemsMessage('查询设备列表失败');
pairingBanner.style.display = 'block';
}
}
});
}
function approveDevice(rid) {
pairingMsg.style.display = 'block';
pairingMsg.style.color = '#1890ff';
pairingMsg.textContent = '⏳ 正在批准设备配对...';
var postData = { token: ocCsrfToken };
if (rid) {
postData.request_id = rid;
} else {
postData.all = '1';
}
(new XHR()).post(devicesApproveUrl, postData, function(x) {
try {
var res = JSON.parse(x.responseText);
if (res.status === 'ok') {
if ((res.success_count || 0) > 0) {
pairingMsg.style.color = '#52c41a';
pairingMsg.textContent = '✅ ' + (res.message || '批准成功!') + ' 请在控制台窗口刷新页面。';
setTimeout(function() {
checkDevices(false);
}, 1500);
} else {
pairingMsg.style.color = '#faad14';
pairingMsg.textContent = 'ℹ️ ' + (res.message || '当前没有待配对的设备请求');
setTimeout(function() {
checkDevices(false);
}, 1500);
}
} else if (res.status === 'partial' || res.status === 'unconfirmed') {
pairingMsg.style.color = '#d48806';
pairingMsg.textContent = '⚠️ ' + (res.message || '部分设备状态尚未确认,请刷新后重试');
setTimeout(function() { checkDevices(true); }, 1500);
} else {
pairingMsg.style.color = '#cf222e';
pairingMsg.textContent = '❌ ' + (res.message || '批准失败');
}
} catch(e) {
pairingMsg.style.color = '#cf222e';
pairingMsg.textContent = '❌ 批准失败:请求异常';
}
});
}
if (pairingApproveAllBtn) {
pairingApproveAllBtn.addEventListener('click', function() {
approveDevice(null);
});
}
if (pairingRefreshBtn) {
pairingRefreshBtn.addEventListener('click', function() {
checkDevices(true);
});
}
if (pairingToggleBtn) {
pairingToggleBtn.addEventListener('click', function() {
if (pairingBanner.style.display === 'none') {
checkDevices(true);
} else {
pairingBanner.style.display = 'none';
}
});
}
checkAndLoad();
})();
//]]>
</script>
<%+footer%>