mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 07:03:56 +08:00
448 lines
16 KiB
HTML
448 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>一万AI分享 OpenClaw 配置管理</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{width:100%;height:100%;overflow:hidden;background:#1a1b26;font-family:system-ui,-apple-system,sans-serif}
|
|
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#7aa2f7;z-index:100;background:#1a1b26;transition:opacity .4s}
|
|
#loading.hidden{opacity:0;pointer-events:none}
|
|
#loading .spinner{width:40px;height:40px;border:3px solid #2a2b3d;border-top:3px solid #ff9e64;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:16px}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
#loading h2{font-size:18px;font-weight:500;margin-bottom:8px}
|
|
#loading p{font-size:13px;color:#565f89}
|
|
#loading .debug{font-size:11px;color:#3b3d57;margin-top:12px;max-width:90%;word-break:break-all;text-align:center}
|
|
#terminal-container{width:100%;height:calc(100% - 36px);position:absolute;top:36px;left:0;right:0;bottom:0;padding:4px;overflow:hidden}
|
|
.xterm{height:100%!important}
|
|
.xterm-viewport::-webkit-scrollbar{width:8px}
|
|
.xterm-viewport::-webkit-scrollbar-thumb{background:#2a2b3d;border-radius:4px}
|
|
.xterm-viewport::-webkit-scrollbar-thumb:hover{background:#3b3d57}
|
|
#topbar{position:fixed;top:0;left:0;right:0;height:36px;background:#16161e;border-bottom:1px solid #2a2b3d;display:flex;align-items:center;padding:0 12px;z-index:50;gap:8px}
|
|
#topbar .logo{font-size:14px;color:#ff9e64;font-weight:600}
|
|
#topbar .status{font-size:11px;padding:2px 8px;border-radius:10px;background:#2a2b3d}
|
|
#topbar .status.connected{color:#9ece6a}
|
|
#topbar .status.disconnected{color:#f7768e}
|
|
#topbar .btn{font-size:12px;color:#7aa2f7;background:none;border:1px solid #3b3d57;border-radius:4px;padding:3px 10px;cursor:pointer;margin-left:auto}
|
|
#topbar .btn:hover{background:#2a2b3d}
|
|
#reconnect-overlay{display:none;position:absolute;inset:0;background:rgba(26,27,38,.9);z-index:80;flex-direction:column;align-items:center;justify-content:center;color:#c0caf5}
|
|
#reconnect-overlay.show{display:flex}
|
|
#reconnect-overlay button{margin-top:16px;padding:8px 24px;background:#ff9e64;color:#1a1b26;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:600}
|
|
#reconnect-overlay button:hover{background:#e0884a}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="topbar">
|
|
<span class="logo">🦞 一万AI分享 OpenClaw 配置管理</span>
|
|
<span id="status" class="status disconnected">● 连接中...</span>
|
|
<button class="btn" onclick="restartSession()" title="重新启动配置脚本">🔄 重启</button>
|
|
</div>
|
|
|
|
<div id="loading">
|
|
<div class="spinner"></div>
|
|
<h2>🦞 一万AI分享 OpenClaw 配置管理工具</h2>
|
|
<p id="loading-msg">正在连接终端...</p>
|
|
<div id="loading-debug" class="debug"></div>
|
|
</div>
|
|
|
|
<div id="terminal-container"></div>
|
|
|
|
<div id="reconnect-overlay">
|
|
<p style="font-size:16px;margin-bottom:4px">⚡ 连接已断开</p>
|
|
<p style="font-size:13px;color:#565f89">配置脚本已退出或连接中断</p>
|
|
<button onclick="restartSession()">🔄 重新启动</button>
|
|
</div>
|
|
|
|
<link rel="stylesheet" href="/lib/xterm.min.css">
|
|
<script src="/lib/xterm.min.js"></script>
|
|
<script src="/lib/addon-fit.min.js"></script>
|
|
<script src="/lib/addon-web-links.min.js"></script>
|
|
|
|
<script>
|
|
(function() {
|
|
const statusEl = document.getElementById('status');
|
|
const loadingEl = document.getElementById('loading');
|
|
const reconnectEl = document.getElementById('reconnect-overlay');
|
|
const containerEl = document.getElementById('terminal-container');
|
|
const loadingMsg = document.getElementById('loading-msg');
|
|
const loadingDebug = document.getElementById('loading-debug');
|
|
|
|
// ── 创建终端 ──
|
|
const term = new window.Terminal({
|
|
cursorBlink: true,
|
|
cursorStyle: 'bar',
|
|
fontSize: 15,
|
|
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", "Source Code Pro", Menlo, Monaco, "Courier New", monospace',
|
|
lineHeight: 1.2,
|
|
scrollback: 5000,
|
|
allowProposedApi: true,
|
|
theme: {
|
|
background: '#1a1b26',
|
|
foreground: '#c0caf5',
|
|
cursor: '#ff9e64',
|
|
cursorAccent: '#1a1b26',
|
|
selectionBackground: '#33467c',
|
|
selectionForeground: '#c0caf5',
|
|
black: '#15161e',
|
|
red: '#f7768e',
|
|
green: '#9ece6a',
|
|
yellow: '#e0af68',
|
|
blue: '#7aa2f7',
|
|
magenta: '#bb9af7',
|
|
cyan: '#7dcfff',
|
|
white: '#a9b1d6',
|
|
brightBlack: '#414868',
|
|
brightRed: '#f7768e',
|
|
brightGreen: '#9ece6a',
|
|
brightYellow: '#e0af68',
|
|
brightBlue: '#7aa2f7',
|
|
brightMagenta: '#bb9af7',
|
|
brightCyan: '#7dcfff',
|
|
brightWhite: '#c0caf5',
|
|
}
|
|
});
|
|
|
|
const fitAddon = new window.FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
try {
|
|
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
term.loadAddon(webLinksAddon);
|
|
} catch(e) { /* optional */ }
|
|
|
|
term.open(containerEl);
|
|
|
|
function doFit() {
|
|
try { fitAddon.fit(); } catch(e) { /* ignore */ }
|
|
try { term.scrollToBottom(); } catch(e) { /* ignore */ }
|
|
}
|
|
doFit();
|
|
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
|
|
|
|
// 监听 iframe 容器大小变化 (FnOS 桌面可能调整窗口大小)
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
new ResizeObserver(function() { setTimeout(doFit, 50); }).observe(containerEl);
|
|
}
|
|
|
|
// ── WebSocket 连接 ──
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
// 从 URL 参数或 sessionStorage 读取 PTY token 用于 WebSocket 认证
|
|
const urlParams = new URLSearchParams(location.search);
|
|
let ptyToken = urlParams.get('pty_token') || '';
|
|
if (ptyToken) {
|
|
try { sessionStorage.setItem('openclaw_pty_token', ptyToken); } catch(e) {}
|
|
} else {
|
|
try { ptyToken = sessionStorage.getItem('openclaw_pty_token') || ''; } catch(e) {}
|
|
}
|
|
|
|
// 取到 token 后立刻从地址栏移除: 否则它会留在浏览器历史、
|
|
// 以及任何 Referer/截图里。内存与 sessionStorage 中的 ptyToken 仍可用于建连。
|
|
if (ptyToken && window.history && history.replaceState) {
|
|
try {
|
|
const clean = new URL(location.href);
|
|
clean.searchParams.delete('pty_token');
|
|
history.replaceState(null, '', clean.pathname + (clean.search || '') + clean.hash);
|
|
} catch (e) {}
|
|
}
|
|
|
|
// 连接序号与单飞状态机
|
|
let currentConnectSeq = 0;
|
|
let wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
|
// 展示用 URL: 隐去 token。真实 wsUrl 只用于建立连接,
|
|
// 不写入页面文本或 console —— 否则 token 会留在截图、录屏与浏览器历史里。
|
|
let wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
|
|
let ws = null;
|
|
let connected = false;
|
|
let isConnecting = false;
|
|
let retryCount = 0;
|
|
const MAX_RETRY = Infinity;
|
|
let retryTimer = null;
|
|
let wasEverConnected = false;
|
|
let pingTimer = null;
|
|
|
|
// 重启配置终端: 原地平滑重连,清屏并重连 WebSocket,无需重新加载整个页面
|
|
window.restartSession = function() {
|
|
console.log('[oc-config] restartSession invoked');
|
|
currentConnectSeq++;
|
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
if (ws) {
|
|
try {
|
|
ws.onopen = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
ws.onmessage = null;
|
|
ws.close();
|
|
} catch(e) {}
|
|
ws = null;
|
|
}
|
|
connected = false;
|
|
isConnecting = false;
|
|
retryCount = 0;
|
|
|
|
// 清空终端并显示重启中状态
|
|
try { term.clear(); } catch(e) {}
|
|
reconnectEl.classList.remove('show');
|
|
loadingEl.classList.remove('hidden');
|
|
loadingMsg.textContent = '正在重启终端会话...';
|
|
statusEl.textContent = '● 重启中...';
|
|
statusEl.className = 'status disconnected';
|
|
|
|
connect();
|
|
};
|
|
|
|
// 跨窗口通信: 向父页面 (LuCI) 请求缺失的 token,或接收父页面发送的 token
|
|
function requestParentToken() {
|
|
if (window.parent && window.parent !== window) {
|
|
try {
|
|
window.parent.postMessage({ type: 'openclaw_request_token' }, '*');
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
|
|
window.addEventListener('message', function(ev) {
|
|
if (ev.data && ev.data.type === 'openclaw_token' && ev.data.pty_token) {
|
|
ptyToken = ev.data.pty_token;
|
|
try { sessionStorage.setItem('openclaw_pty_token', ptyToken); } catch(e) {}
|
|
wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
|
wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
|
|
if (!connected) {
|
|
currentConnectSeq++;
|
|
if (ws) {
|
|
try {
|
|
ws.onopen = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
ws.onmessage = null;
|
|
ws.close();
|
|
} catch(e){}
|
|
ws = null;
|
|
}
|
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
isConnecting = false;
|
|
connect();
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('[oc-config] protocol:', location.protocol);
|
|
console.log('[oc-config] host:', location.host, 'port:', location.port);
|
|
console.log('[oc-config] WebSocket URL:', wsUrlSafe);
|
|
loadingDebug.textContent = wsUrlSafe;
|
|
|
|
function connect() {
|
|
if (isConnecting || connected) return;
|
|
isConnecting = true;
|
|
const mySeq = ++currentConnectSeq;
|
|
|
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
if (ws) {
|
|
try {
|
|
ws.onopen = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
ws.onmessage = null;
|
|
ws.close();
|
|
} catch(e) {}
|
|
ws = null;
|
|
}
|
|
|
|
if (!ptyToken) {
|
|
try { ptyToken = sessionStorage.getItem('openclaw_pty_token') || ''; } catch(e) {}
|
|
}
|
|
if (!ptyToken) {
|
|
requestParentToken();
|
|
}
|
|
wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
|
wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
|
|
|
|
retryCount++;
|
|
console.log('[oc-config] Connecting to', wsUrlSafe, '(attempt', retryCount + ')');
|
|
loadingMsg.textContent = retryCount > 1
|
|
? '正在重新连接... (第 ' + retryCount + ' 次)'
|
|
: '正在连接终端...';
|
|
loadingDebug.textContent = wsUrlSafe;
|
|
|
|
// 先用带超时的 HTTP /health 预检确认服务端就绪
|
|
var controller = null;
|
|
var signal = null;
|
|
if (typeof AbortController !== 'undefined') {
|
|
controller = new AbortController();
|
|
signal = controller.signal;
|
|
setTimeout(function() {
|
|
try { controller.abort(); } catch(e) {}
|
|
}, 2500);
|
|
}
|
|
|
|
fetch('/health', { signal: signal, cache: 'no-store' }).then(function(r) {
|
|
return r.json();
|
|
}).then(function(data) {
|
|
if (mySeq !== currentConnectSeq) return;
|
|
console.log('[oc-config] Health check OK:', JSON.stringify(data));
|
|
doWebSocket(mySeq);
|
|
}).catch(function(err) {
|
|
if (mySeq !== currentConnectSeq) return;
|
|
console.log('[oc-config] Health check failed:', err.message || err);
|
|
isConnecting = false;
|
|
scheduleRetry();
|
|
});
|
|
}
|
|
|
|
function doWebSocket(mySeq) {
|
|
if (mySeq !== currentConnectSeq || connected) {
|
|
isConnecting = false;
|
|
return;
|
|
}
|
|
isConnecting = true;
|
|
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
} catch(e) {
|
|
console.error('[oc-config] WebSocket constructor error:', e);
|
|
isConnecting = false;
|
|
scheduleRetry();
|
|
return;
|
|
}
|
|
|
|
console.log('[oc-config] WebSocket created, readyState:', ws.readyState);
|
|
|
|
// 连接超时保护: 5秒没连上就关闭当前 socket,由 onclose 统一调度重试
|
|
var connectTimeout = setTimeout(function() {
|
|
if (mySeq !== currentConnectSeq) return;
|
|
if (ws && ws.readyState !== WebSocket.OPEN) {
|
|
console.log('[oc-config] Connection timeout, readyState:', ws.readyState);
|
|
try { ws.close(); } catch(e){}
|
|
}
|
|
}, 5000);
|
|
|
|
ws.onopen = function() {
|
|
if (mySeq !== currentConnectSeq) {
|
|
try { ws.close(); } catch(e){}
|
|
return;
|
|
}
|
|
clearTimeout(connectTimeout);
|
|
connected = true;
|
|
isConnecting = false;
|
|
var isReconnect = wasEverConnected;
|
|
wasEverConnected = true;
|
|
retryCount = 0;
|
|
loadingEl.classList.add('hidden');
|
|
reconnectEl.classList.remove('show');
|
|
statusEl.textContent = '● 已连接';
|
|
statusEl.className = 'status connected';
|
|
// 重连时清屏,避免旧内容和新菜单混在一起
|
|
if (isReconnect) {
|
|
term.clear();
|
|
term.write('\x1b[33m⚡ 已重新连接,配置脚本已重新启动\x1b[0m\r\n\r\n');
|
|
}
|
|
// 连接后重新 fit,确保尺寸正确
|
|
setTimeout(doFit, 50);
|
|
term.focus();
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
} catch(e) {}
|
|
// 客户端心跳: 每 20 秒发送一次,防止连接被浏览器/代理超时关闭
|
|
if (pingTimer) clearInterval(pingTimer);
|
|
pingTimer = setInterval(function() {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
|
|
}
|
|
}, 20000);
|
|
};
|
|
|
|
ws.onmessage = function(ev) {
|
|
if (typeof ev.data === 'string') {
|
|
// 过滤掉服务端的 pong 心跳消息,不写入终端
|
|
if (ev.data.indexOf('"type":"pong"') !== -1) return;
|
|
term.write(ev.data, function() { term.scrollToBottom(); });
|
|
} else if (ev.data instanceof Blob) {
|
|
ev.data.text().then(function(text) {
|
|
if (text.indexOf('"type":"pong"') !== -1) return;
|
|
term.write(text, function() { term.scrollToBottom(); });
|
|
});
|
|
}
|
|
};
|
|
|
|
ws.onclose = function(ev) {
|
|
clearTimeout(connectTimeout);
|
|
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
if (mySeq !== currentConnectSeq) return;
|
|
connected = false;
|
|
isConnecting = false;
|
|
ws = null;
|
|
statusEl.textContent = '● 已断开';
|
|
statusEl.className = 'status disconnected';
|
|
console.log('[oc-config] WebSocket closed, code:', ev.code, 'reason:', ev.reason);
|
|
scheduleRetry();
|
|
};
|
|
|
|
ws.onerror = function(ev) {
|
|
clearTimeout(connectTimeout);
|
|
if (mySeq !== currentConnectSeq) return;
|
|
connected = false;
|
|
isConnecting = false;
|
|
console.error('[oc-config] WebSocket error, will retry');
|
|
// 不要在这里做UI更新, onclose 会紧跟着触发
|
|
};
|
|
}
|
|
|
|
function scheduleRetry() {
|
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
isConnecting = false;
|
|
|
|
if (!ptyToken) {
|
|
requestParentToken();
|
|
}
|
|
if (retryCount >= MAX_RETRY) {
|
|
loadingMsg.textContent = '连接失败,请检查服务状态';
|
|
loadingDebug.textContent = '已重试 ' + MAX_RETRY + ' 次。请刷新页面重试。';
|
|
loadingEl.classList.remove('hidden');
|
|
statusEl.textContent = '● 连接失败';
|
|
statusEl.className = 'status disconnected';
|
|
return;
|
|
}
|
|
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ... 最大 5s
|
|
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
|
|
loadingEl.classList.remove('hidden');
|
|
if (wasEverConnected) {
|
|
statusEl.textContent = '● 重连中...';
|
|
loadingMsg.textContent = '连接中断,等待服务就绪... ' + Math.ceil(delay / 1000) + '秒后重试';
|
|
} else {
|
|
loadingMsg.textContent = (!ptyToken)
|
|
? '等待服务就绪或获取认证令牌... ' + Math.ceil(delay / 1000) + '秒后重试'
|
|
: '等待服务就绪... ' + Math.ceil(delay / 1000) + '秒后重试';
|
|
}
|
|
loadingDebug.textContent = wsUrlSafe;
|
|
console.log('[oc-config] Retry in', delay, 'ms');
|
|
retryTimer = setTimeout(function() {
|
|
retryTimer = null;
|
|
connect();
|
|
}, delay);
|
|
}
|
|
|
|
term.onData(function(data) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'stdin', data: data }));
|
|
}
|
|
});
|
|
|
|
term.onResize(function(size) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
|
}
|
|
});
|
|
|
|
// 等 DOM 和资源就绪后再连接
|
|
if (document.readyState === 'complete') {
|
|
connect();
|
|
} else {
|
|
window.addEventListener('load', connect);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|