<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iLink Hub — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e2e5f0;
--muted: #7c8196;
--green: #22c55e;
--red: #ef4444;
--blue: #3b82f6;
--blue-hover: #2563eb;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 12px;
}
header h1 { font-size: 18px; font-weight: 600; }
header .badge {
font-size: 11px;
background: var(--blue);
color: #fff;
padding: 2px 8px;
border-radius: 20px;
}
#status-bar {
font-size: 12px;
color: var(--muted);
margin-left: auto;
}
main { max-width: 900px; margin: 0 auto; padding: 28px 20px; }
section { margin-bottom: 32px; }
section h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; color: var(--text); }
#clients-table-wrap { overflow-x: auto; }
#clients-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
#clients-table th {
text-align: left;
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
#clients-table td {
padding: 10px 10px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
#clients-table tr:last-child td { border-bottom: none; }
#clients-table tr.client-row:hover > td { background: rgba(255,255,255,0.02); }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 5px var(--green); }
.status-dot.offline { background: var(--muted); }
.client-name-col { display: flex; align-items: center; gap: 8px; }
.client-name-col .name { font-weight: 600; }
.client-name-col .label { font-size: 12px; color: var(--muted); }
.client-actions {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
white-space: nowrap;
}
.cred-row { display: none; }
.cred-row.visible { display: table-row; }
.cred-row td {
padding: 0 10px 10px 10px;
background: var(--bg);
}
.cred-inner {
display: flex;
flex-direction: column;
gap: 6px;
}
.vtoken-row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-family: monospace;
font-size: 12px;
color: var(--muted);
overflow: hidden;
}
.vtoken-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edit-row { display: none; }
.edit-row.visible { display: table-row; }
.edit-row td {
padding: 10px;
background: color-mix(in srgb, var(--surface) 80%, var(--bg));
border-bottom: 1px solid var(--border);
}
.client-edit { display: flex; flex-direction: column; gap: 10px; }
.client-edit .field { min-width: 0; }
.client-edit .form-row { align-items: flex-end; }
button, .btn {
border: none; cursor: pointer; border-radius: 6px; font-family: var(--font);
font-size: 12px; font-weight: 500; padding: 5px 12px; transition: background 0.15s;
}
.btn-primary { background: var(--blue); color: #fff; }
.btn-primary:hover { background: var(--blue-hover); }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { background: var(--border); color: var(--text); }
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid #7f1d1d;
}
.btn-danger:hover { background: #7f1d1d; color: #fff; }
.btn-sm { font-size: 11px; padding: 3px 8px; }
.form-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
input[type="text"], input[type="password"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 7px 10px;
flex: 1;
min-width: 140px;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: var(--blue); }
input::placeholder { color: var(--muted); }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.field { flex: 1; min-width: 120px; }
#reg-result {
display: none;
margin-top: 14px;
background: var(--bg);
border: 1px solid var(--green);
border-radius: var(--radius);
padding: 14px;
}
#reg-result h3 { font-size: 13px; color: var(--green); margin-bottom: 10px; }
.env-block {
background: #0d1117;
border-radius: 6px;
padding: 10px 12px;
font-family: monospace;
font-size: 12px;
color: #a3e635;
margin-bottom: 10px;
white-space: pre;
overflow-x: auto;
}
#toast {
position: fixed; bottom: 20px; right: 20px;
background: #1c2333; border: 1px solid var(--border);
color: var(--text); font-size: 13px;
padding: 10px 16px; border-radius: 8px;
opacity: 0; pointer-events: none;
transition: opacity 0.2s;
}
#toast.show { opacity: 1; }
.empty { color: var(--muted); font-size: 13px; text-align: center; padding: 24px 0; }
.online-count { font-weight: 400; color: var(--muted); font-size: 13px; margin-left: 8px; }
#auth-warning {
background: #1f1200; border: 1px solid #7c4f00; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #f59e0b; margin-bottom: 18px;
display: none;
}
#login-overlay {
position: fixed; inset: 0;
background: rgba(15, 17, 23, 0.92);
backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
#login-overlay.hidden { display: none; }
#login-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 32px;
width: 100%;
max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
#login-box .login-logo {
display: flex; align-items: center; gap: 10px;
margin-bottom: 24px;
}
#login-box .login-logo h2 { font-size: 18px; font-weight: 600; }
#login-box .login-logo .badge {
font-size: 11px; background: var(--blue); color: #fff;
padding: 2px 8px; border-radius: 20px;
}
#login-box p {
font-size: 13px; color: var(--muted); margin-bottom: 20px; line-height: 1.6;
}
#login-box .login-field { margin-bottom: 16px; }
#login-box .login-field label {
display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px;
}
#login-box .login-field input {
width: 100%; padding: 9px 12px; font-size: 14px;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-family: var(--font);
outline: none; transition: border-color 0.15s;
}
#login-box .login-field input:focus { border-color: var(--blue); }
#login-box .login-btn {
width: 100%; padding: 10px; font-size: 14px; font-weight: 600;
background: var(--blue); color: #fff; border: none;
border-radius: 6px; cursor: pointer; transition: background 0.15s;
}
#login-box .login-btn:hover { background: var(--blue-hover); }
#login-box .login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#login-error {
font-size: 12px; color: var(--red); margin-top: 10px;
display: none;
}
#logout-btn {
font-size: 11px; padding: 4px 10px;
background: transparent; color: var(--muted);
border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; transition: all 0.15s;
display: none;
}
#logout-btn:hover { background: var(--border); color: var(--text); }
#detail-view { display: none; }
#detail-view.visible { display: block; }
.detail-header {
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
}
.detail-header .back-btn {
background: transparent; border: 1px solid var(--border);
color: var(--muted); border-radius: 6px; padding: 5px 10px;
font-size: 12px; cursor: pointer; transition: all 0.15s;
}
.detail-header .back-btn:hover { background: var(--border); color: var(--text); }
.detail-header h2 { font-size: 15px; font-weight: 600; }
.detail-header .detail-badge {
font-size: 11px; padding: 2px 8px; border-radius: 20px;
border: 1px solid var(--border); color: var(--muted);
}
.detail-header .detail-badge.online { border-color: #166534; color: var(--green); background: #052e16; }
.detail-header .detail-badge.offline { background: transparent; }
.session-list { display: flex; flex-direction: column; gap: 8px; }
.session-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px;
cursor: pointer; transition: border-color 0.15s, background 0.15s;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.session-card:hover { border-color: var(--blue); }
.session-card.active { border-color: #1d4ed8; background: #0c1a3a; }
.session-card-left { flex: 1; min-width: 0; }
.session-card-name {
font-weight: 600; font-size: 13px; margin-bottom: 3px;
display: flex; align-items: center; gap: 6px;
}
.session-active-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--blue); flex-shrink: 0; display: inline-block;
}
.session-card-snippet {
font-size: 12px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 520px;
}
.session-card-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
.waiting-badge {
font-size: 10px; padding: 1px 6px; border-radius: 10px;
background: #1f0808; border: 1px solid #7f1d1d; color: var(--red);
}
#history-panel { display: none; }
#history-panel.visible { display: block; }
.history-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
}
.history-header h3 { font-size: 14px; font-weight: 600; }
.message-list {
display: flex; flex-direction: column; gap: 10px;
}
.message-bubble {
max-width: 78%; padding: 10px 14px;
border-radius: 10px; font-size: 13px; line-height: 1.55;
word-break: break-word; white-space: pre-wrap;
}
.message-bubble.user {
align-self: flex-end;
background: #1d3a5c; border: 1px solid #1d4ed8; color: var(--text);
border-bottom-right-radius: 3px;
}
.message-bubble.assistant {
align-self: flex-start;
background: var(--surface); border: 1px solid var(--border); color: var(--text);
border-bottom-left-radius: 3px;
}
.message-meta {
font-size: 10px; color: var(--muted);
margin-top: 3px; padding: 0 2px;
}
.message-meta.user { text-align: right; }
.message-wrap { display: flex; flex-direction: column; }
.message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; }
.history-empty { color: var(--muted); font-size: 13px; padding: 20px 0; text-align: center; }
#ilink-section .status-row {
display: flex; align-items: center; gap: 12px; margin-bottom: 14px;
}
#ilink-status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 500; padding: 4px 12px;
border-radius: 20px; border: 1px solid var(--border);
}
#ilink-status-badge.connected { border-color: #166534; color: var(--green); background: #052e16; }
#ilink-status-badge.needs_login,
#ilink-status-badge.unknown { border-color: #7f1d1d; color: var(--red); background: #1f0808; }
#ilink-status-badge.logging_in { border-color: #1d4ed8; color: #93c5fd; background: #0c1a3a; }
#qr-area {
display: none;
flex-direction: column; align-items: flex-start; gap: 10px;
margin-top: 12px;
}
#qr-area.visible { display: flex; }
#qr-img {
width: 200px; height: 200px;
border: 1px solid var(--border); border-radius: var(--radius);
background: #fff;
}
#qr-hint { font-size: 12px; color: var(--muted); }
</style>
</head>
<body>
<div id="login-overlay">
<div id="login-box">
<div class="login-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<h2>iLink Hub</h2>
<span class="badge">Admin</span>
</div>
<p>请输入管理台访问密码(即 <code style="font-family:monospace;color:#a3e635">ILINK_ADMIN_TOKEN</code> 的值)。</p>
<div class="login-field">
<label for="login-token-input">Admin Token</label>
<input type="password" id="login-token-input" placeholder="请输入 Admin Token" autocomplete="current-password" />
</div>
<button class="login-btn" id="login-btn" onclick="doLogin()">登录</button>
<div id="login-error">Token 错误,请重试</div>
</div>
</div>
<header>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<h1>iLink Hub</h1>
<span class="badge">Admin</span>
<span id="status-bar">Connecting…</span>
<button id="logout-btn" onclick="doLogout()">退出登录</button>
</header>
<main>
<div id="auth-warning">
⚠️ Admin token required — set <code>ILINK_ADMIN_TOKEN</code> env var and reload with
<code>Authorization: Bearer <token></code> header, or use a reverse proxy.
</div>
<section id="ilink-section">
<h2>微信连接状态</h2>
<div class="form-card">
<div class="status-row">
<span id="ilink-status-badge" class="unknown">⬤ 获取中…</span>
<button class="btn btn-ghost btn-sm" id="relogin-btn" onclick="triggerRelogin()">重新绑定微信</button>
</div>
<div id="qr-area">
<img id="qr-img" src="" alt="WeChat QR Code" />
<span id="qr-hint">请用微信扫描上方二维码完成登录</span>
</div>
</div>
</section>
<section>
<h2>Connected Workspaces <span class="online-count" id="online-count"></span></h2>
<div class="form-card" style="padding:0;overflow:hidden">
<div id="clients-table-wrap">
<table id="clients-table">
<thead>
<tr>
<th>Backend</th>
<th>Persona</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="clients-tbody"><tr><td colspan="3" class="empty" style="text-align:center;padding:24px">Loading…</td></tr></tbody>
</table>
</div>
</div>
</section>
<section>
<h2>Register New Backend</h2>
<div class="form-card">
<div class="form-row">
<div class="field">
<label for="reg-name">Name (machine-readable)</label>
<input type="text" id="reg-name" placeholder="mac-home" />
</div>
<div class="field">
<label for="reg-label">Label (optional)</label>
<input type="text" id="reg-label" placeholder="Mac Home — Claude" />
</div>
<div class="field" id="token-field" style="display:none">
<label for="admin-token">Admin Token</label>
<input type="password" id="admin-token" placeholder="ILINK_ADMIN_TOKEN value" />
</div>
<div style="align-self:flex-end">
<button class="btn btn-primary" onclick="registerClient()">Register</button>
</div>
</div>
<div id="reg-result">
<h3>✅ Client registered!</h3>
<p style="font-size:12px;color:var(--muted);margin-bottom:8px">
Configure your backend with these environment variables:
</p>
<div class="env-block" id="reg-env-block"></div>
<button class="btn btn-ghost btn-sm" onclick="copyEnv()">Copy to clipboard</button>
</div>
</div>
</section>
<div id="detail-view">
<div class="detail-header">
<button class="back-btn" onclick="closeDetailView()">← Back</button>
<h2 id="detail-client-name"></h2>
<span id="detail-client-badge" class="detail-badge"></span>
</div>
<section id="sessions-section">
<h2>Sessions</h2>
<div id="sessions-list" class="session-list">
<div class="empty">Loading sessions…</div>
</div>
</section>
<div id="history-panel">
<div class="form-card" style="padding:18px">
<div class="history-header">
<button class="btn btn-ghost btn-sm" onclick="closeHistory()">← Sessions</button>
<h3 id="history-session-name"></h3>
<span id="history-msg-count" style="font-size:11px;color:var(--muted);margin-left:auto"></span>
</div>
<div id="history-messages" class="message-list"></div>
</div>
</div>
</div>
<section>
<h2>WeChat Commands</h2>
<div class="form-card">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<colgroup><col style="width:220px"/><col/></colgroup>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/list <span style="color:var(--muted);font-size:11px">· /ls</span></td>
<td style="padding:8px 0;color:var(--muted)">列出所有已注册后端及其状态</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/use <name> <span style="color:var(--muted);font-size:11px">· /switch</span></td>
<td style="padding:8px 0;color:var(--muted)">切换当前活跃后端(如 <code style="font-family:monospace">/use mac-home</code>)</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/broadcast <text> <span style="color:var(--muted);font-size:11px">· /all</span></td>
<td style="padding:8px 0;color:var(--muted)">向所有在线后端广播消息</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/status</td>
<td style="padding:8px 0;color:var(--muted)">查看 Hub 状态(在线/总客户端数)</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/help <span style="color:var(--muted);font-size:11px">· /?</span></td>
<td style="padding:8px 0;color:var(--muted)">显示帮助信息</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/session list <span style="color:var(--muted);font-size:11px">· /session ls</span></td>
<td style="padding:8px 0;color:var(--muted)">列出当前对话的所有 sessions</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/session new [name] [uuid]</td>
<td style="padding:8px 0;color:var(--muted)">新建 session(名称可选,省略则自动生成;可附带初始 UUID)</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/session use <name></td>
<td style="padding:8px 0;color:var(--muted)">切换到指定 session</td>
</tr>
<tr>
<td style="padding:8px 0;font-family:monospace;color:#a3e635">/session delete <name> <span style="color:var(--muted);font-size:11px">· /session rm · /session del</span></td>
<td style="padding:8px 0;color:var(--muted)">删除指定 session(不能删除当前活跃的)</td>
</tr>
</table>
</div>
</section>
</main>
<div id="toast"></div>
<script>
const HUB_URL = window.location.origin;
let adminToken = sessionStorage.getItem('ilink_admin_token') || '';
let lastRegEnv = '';
let editingClient = null;
function authHeaders() {
const h = { 'Content-Type': 'application/json' };
if (adminToken) h['Authorization'] = 'Bearer ' + adminToken;
return h;
}
async function loadClients() {
try {
const r = await fetch('/hub/clients', { headers: authHeaders() });
if (r.status === 401 || r.status === 403) {
document.getElementById('status-bar').textContent = '🔒 未认证';
showLoginOverlay();
return;
}
const data = await r.json();
const clients = data.clients || [];
const online = clients.filter(c => c.online).length;
document.getElementById('online-count').textContent = `${online} online / ${clients.length} total`;
document.getElementById('status-bar').textContent = `${online}/${clients.length} online — refreshes every 10s`;
renderClients(clients);
} catch(e) {
document.getElementById('clients-tbody').innerHTML = '<tr><td colspan="3" class="empty" style="text-align:center;padding:24px">Failed to load clients.</td></tr>';
}
}
function renderClients(clients) {
const tbody = document.getElementById('clients-tbody');
if (!clients.length) {
tbody.innerHTML = '<tr><td colspan="3" class="empty" style="text-align:center;padding:24px">No clients registered yet — register one below.</td></tr>';
return;
}
tbody.innerHTML = clients.map(c => {
const isEditing = editingClient && editingClient.oldName === c.name;
const editName = isEditing ? editingClient.name : c.name;
const editLabel = isEditing ? (editingClient.label ?? '') : (c.label || '');
const personaDisplay = (c.persona_emoji || c.persona_name)
? esc((c.persona_emoji ? c.persona_emoji + ' ' : '') + (c.persona_name || ''))
: '<span style="color:var(--muted)">—</span>';
return `
<tr class="client-row" data-client-name="${esc(c.name)}" data-online="${c.online}" data-persona-emoji="${esc(c.persona_emoji || '')}" data-persona-name="${esc(c.persona_name || '')}">
<td>
<div class="client-name-col">
<span class="status-dot ${c.online ? 'online' : 'offline'}"></span>
<div>
<div class="name">${esc(c.name)}</div>
${c.label ? `<div class="label">${esc(c.label)}</div>` : ''}
</div>
</div>
</td>
<td style="color:var(--blue)">${personaDisplay}</td>
<td>
<div class="client-actions">
<button class="btn btn-ghost btn-sm" data-action="sessions">Sessions</button>
<button class="btn btn-ghost btn-sm" data-action="toggle-creds" title="查看 Token / Base URL">Keys</button>
<button class="btn btn-ghost btn-sm" data-action="edit">Edit</button>
${c.online ? '' : `<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`}
</div>
</td>
</tr>
<tr class="cred-row" data-for="${esc(c.name)}">
<td colspan="3">
<div class="cred-inner">
<div class="vtoken-row">
<span style="color:var(--blue);font-size:11px;flex-shrink:0">WEIXIN_TOKEN</span>
<span class="vtoken-value">${esc(c.vtoken || '—')}</span>
${c.vtoken ? `<button class="btn btn-ghost btn-sm" data-action="copy-vtoken" data-copy="${esc(c.vtoken)}">Copy</button>` : ''}
</div>
<div class="vtoken-row">
<span style="color:var(--blue);font-size:11px;flex-shrink:0">WEIXIN_BASE_URL</span>
<span class="vtoken-value">${esc(HUB_URL)}</span>
<button class="btn btn-ghost btn-sm" data-action="copy-hub-url">Copy</button>
</div>
</div>
</td>
</tr>
<tr class="edit-row${isEditing ? ' visible' : ''}" data-for="${esc(c.name)}">
<td colspan="3">
<div class="client-edit">
<div class="form-row">
<div class="field">
<label>Name</label>
<input type="text" data-field="name" value="${esc(editName)}" />
</div>
<div class="field">
<label>Label (optional)</label>
<input type="text" data-field="label" value="${esc(editLabel)}" placeholder="Mac Home — Claude" />
</div>
</div>
<div class="form-row" style="margin-top:8px">
<div class="field" style="max-width:80px">
<label>头像 Emoji</label>
<input type="text" data-field="persona_emoji" value="${esc(isEditing ? (editingClient.persona_emoji ?? '') : (c.persona_emoji || ''))}" placeholder="🤖" />
</div>
<div class="field">
<label>显示名称(发消息时展示)</label>
<input type="text" data-field="persona_name" value="${esc(isEditing ? (editingClient.persona_name ?? '') : (c.persona_name || ''))}" placeholder="Claude" />
</div>
<div style="display:flex;gap:6px;align-self:flex-end">
<button class="btn btn-primary btn-sm" data-action="save">Save</button>
<button class="btn btn-ghost btn-sm" data-action="cancel">Cancel</button>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('');
}
function startEditClient(name, label, personaEmoji, personaName) {
editingClient = { oldName: name, name, label: label || '', persona_emoji: personaEmoji || '', persona_name: personaName || '' };
loadClients();
}
function cancelEditClient() {
editingClient = null;
loadClients();
}
async function saveEditRow(editRow) {
const oldName = editRow.dataset.for;
const name = editRow.querySelector('[data-field="name"]').value.trim();
const label = editRow.querySelector('[data-field="label"]').value.trim();
const personaEmoji = editRow.querySelector('[data-field="persona_emoji"]').value.trim();
const personaName = editRow.querySelector('[data-field="persona_name"]').value.trim();
if (!name) {
showToast('Name is required');
return;
}
const labelChanged = editingClient == null || editingClient.oldName !== oldName
? true
: label !== (editingClient.label || '');
const body = {
name,
persona_emoji: personaEmoji || null,
persona_name: personaName || null,
};
if (labelChanged) {
body.label = label || null;
}
try {
const r = await fetch('/hub/clients/' + encodeURIComponent(oldName), {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify(body),
});
const data = await r.json();
if (data.ret !== 0) {
showToast('Update failed: ' + (data.errmsg || 'unknown error'));
return;
}
editingClient = null;
showToast('Updated ' + name);
await loadClients();
} catch (e) {
showToast('Error: ' + e.message);
}
}
async function deleteClient(name) {
if (!confirm(`Delete offline backend "${name}"?\n\nThis removes it from /list and clears any routing to it.`)) return;
try {
const r = await fetch('/hub/clients/' + encodeURIComponent(name), {
method: 'DELETE',
headers: authHeaders(),
});
const data = await r.json();
if (data.ret !== 0) {
showToast('Delete failed: ' + (data.errmsg || 'unknown error'));
return;
}
showToast('Deleted ' + name);
await loadClients();
} catch(e) {
showToast('Error: ' + e.message);
}
}
async function registerClient() {
const name = document.getElementById('reg-name').value.trim();
const label = document.getElementById('reg-label').value.trim();
const tokenInput = document.getElementById('admin-token').value.trim();
if (!name) { showToast('Name is required'); return; }
if (tokenInput) {
adminToken = tokenInput;
sessionStorage.setItem('ilink_admin_token', adminToken);
}
try {
const r = await fetch('/hub/register', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ name, label: label || null }),
});
const data = await r.json();
if (data.ret !== 0) {
showToast('Registration failed: ' + (data.errmsg || 'unknown error'));
return;
}
const env = `WEIXIN_BASE_URL=${HUB_URL}\nWEIXIN_TOKEN=${data.vtoken}`;
lastRegEnv = env;
document.getElementById('reg-env-block').textContent = env;
document.getElementById('reg-result').style.display = 'block';
showToast('✅ Client registered!');
document.getElementById('reg-name').value = '';
document.getElementById('reg-label').value = '';
await loadClients();
} catch(e) {
showToast('Error: ' + e.message);
}
}
function copyEnv() {
copyText(lastRegEnv);
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => showToast('Copied!'));
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
document.getElementById('clients-table').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const row = btn.closest('.client-row') || btn.closest('.edit-row') || btn.closest('.cred-row');
const name = row ? (row.dataset.clientName || row.dataset.for) : null;
if (action === 'sessions') {
const clientRow = document.querySelector(`.client-row[data-client-name="${CSS.escape(name)}"]`);
const online = clientRow ? clientRow.dataset.online === 'true' : false;
openDetailView(name, online);
return;
}
if (action === 'toggle-creds') {
const credRow = document.querySelector(`.cred-row[data-for="${CSS.escape(name)}"]`);
if (credRow) credRow.classList.toggle('visible');
return;
}
if (action === 'edit') {
const clientRow = document.querySelector(`.client-row[data-client-name="${CSS.escape(name)}"]`);
const label = clientRow ? (clientRow.querySelector('.label')?.textContent || '') : '';
const personaEmoji = clientRow?.dataset.personaEmoji || '';
const personaName = clientRow?.dataset.personaName || '';
startEditClient(name, label, personaEmoji, personaName);
return;
}
if (action === 'cancel') {
cancelEditClient();
return;
}
if (action === 'save') {
const editRow = btn.closest('.edit-row');
saveEditRow(editRow);
return;
}
if (action === 'delete') {
deleteClient(name);
return;
}
if (action === 'copy-vtoken') {
copyText(btn.dataset.copy || '');
return;
}
if (action === 'copy-hub-url') {
copyText(HUB_URL);
}
});
let detailClientName = '';
let detailClientOnline = false;
function openDetailView(name, online) {
detailClientName = name;
detailClientOnline = online;
document.getElementById('detail-client-name').textContent = name;
const badge = document.getElementById('detail-client-badge');
badge.textContent = online ? '● online' : '● offline';
badge.className = 'detail-badge ' + (online ? 'online' : 'offline');
document.querySelectorAll('main > section').forEach(s => s.style.display = 'none');
document.getElementById('detail-view').classList.add('visible');
closeHistory();
loadSessions(name);
}
function closeDetailView() {
document.getElementById('detail-view').classList.remove('visible');
document.querySelectorAll('main > section').forEach(s => s.style.display = '');
detailClientName = '';
}
async function loadSessions(clientName) {
const container = document.getElementById('sessions-list');
container.innerHTML = '<div class="empty">Loading…</div>';
try {
const r = await fetch(`/hub/clients/${encodeURIComponent(clientName)}/sessions`, {
headers: authHeaders(),
});
const data = await r.json();
if (data.ret !== 0) {
container.innerHTML = `<div class="empty">Failed: ${esc(data.errmsg || 'unknown error')}</div>`;
return;
}
renderSessions(clientName, data.sessions || []);
} catch (e) {
container.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`;
}
}
function renderSessions(clientName, sessions) {
const container = document.getElementById('sessions-list');
if (!sessions.length) {
container.innerHTML = '<div class="empty">暂无 session 记录(backend 尚未发起过对话)</div>';
return;
}
container.innerHTML = sessions.map(s => {
const snippet = s.last_user_content
? esc(s.last_user_content.slice(0, 80) + (s.last_user_content.length > 80 ? '…' : ''))
: '<span style="color:var(--muted);font-style:italic">(无用户消息)</span>';
const timeStr = s.user_msg_created_at
? `<span title="${esc(s.user_msg_created_at)}">${formatRelTime(s.user_msg_created_at)}</span>`
: '';
const waitBadge = s.waiting_for_reply
? `<span class="waiting-badge">等待回复</span>`
: '';
return `
<div class="session-card" data-session-name="${esc(s.name)}" onclick="openHistory(${JSON.stringify(clientName)}, ${JSON.stringify(s.name)})">
<div class="session-card-left">
<div class="session-card-name">
<span>${esc(s.name)}</span>
${waitBadge}
</div>
<div class="session-card-snippet">${snippet}</div>
</div>
<div class="session-card-meta">${timeStr}</div>
</div>
`;
}).join('');
}
async function openHistory(clientName, sessionName) {
document.getElementById('sessions-section').style.display = 'none';
const panel = document.getElementById('history-panel');
panel.classList.add('visible');
document.getElementById('history-session-name').textContent = sessionName;
document.getElementById('history-messages').innerHTML = '<div class="history-empty">Loading…</div>';
document.getElementById('history-msg-count').textContent = '';
try {
const r = await fetch(
`/hub/clients/${encodeURIComponent(clientName)}/sessions/${encodeURIComponent(sessionName)}/history?limit=200`,
{ headers: authHeaders() }
);
const data = await r.json();
if (data.ret !== 0) {
document.getElementById('history-messages').innerHTML =
`<div class="history-empty">Failed: ${esc(data.errmsg || 'unknown')}</div>`;
return;
}
const msgs = (data.messages || []).slice().reverse(); document.getElementById('history-msg-count').textContent =
msgs.length ? `${msgs.length} 条消息` : '';
renderHistory(msgs);
} catch (e) {
document.getElementById('history-messages').innerHTML =
`<div class="history-empty">Error: ${esc(e.message)}</div>`;
}
}
function renderHistory(messages) {
const container = document.getElementById('history-messages');
if (!messages.length) {
container.innerHTML = '<div class="history-empty">暂无消息记录</div>';
return;
}
container.innerHTML = messages.map(m => {
const role = m.role === 'user' ? 'user' : 'assistant';
const label = role === 'user' ? '用户' : 'AI';
const timeStr = m.created_at ? formatRelTime(m.created_at) : '';
return `
<div class="message-wrap ${role}">
<div class="message-bubble ${role}">${esc(m.content || '')}</div>
<div class="message-meta ${role}">${label} · ${timeStr}</div>
</div>
`;
}).join('');
container.scrollTop = container.scrollHeight;
}
function closeHistory() {
document.getElementById('history-panel').classList.remove('visible');
document.getElementById('sessions-section').style.display = '';
}
function formatRelTime(isoStr) {
try {
const d = new Date(isoStr.endsWith('Z') ? isoStr : isoStr + 'Z');
const diff = Date.now() - d.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前';
if (diff < 7 * 86400000) return Math.floor(diff / 86400000) + ' 天前';
return d.toLocaleDateString('zh-CN');
} catch { return isoStr || ''; }
}
let qrEventSource = null;
async function loadIlinkStatus() {
try {
const r = await fetch('/hub/ilink/status', { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
setIlinkBadge(d.status);
} catch(e) {}
}
function setIlinkBadge(status) {
const badge = document.getElementById('ilink-status-badge');
badge.className = status;
const labels = {
connected: '🟢 已连接',
needs_login: '🔴 需要登录',
logging_in: '🔵 扫码中…',
unknown: '⬤ 未知',
};
badge.textContent = labels[status] || '⬤ ' + status;
const qrArea = document.getElementById('qr-area');
if (status === 'logging_in') {
qrArea.classList.add('visible');
startQrStream();
} else {
qrArea.classList.remove('visible');
stopQrStream();
if (status !== 'logging_in') {
document.getElementById('qr-img').src = '';
}
}
}
async function startQrStream() {
if (qrEventSource) return;
let url = '/hub/ilink/qr-stream';
if (adminToken) {
try {
const r = await fetch('/hub/ilink/qr-stream-ticket', {
method: 'POST',
headers: authHeaders(),
});
if (!r.ok) { stopQrStream(); return; }
const d = await r.json();
if (!d.ticket) { stopQrStream(); return; }
url = `/hub/ilink/qr-stream?ticket=${encodeURIComponent(d.ticket)}`;
} catch (err) {
stopQrStream();
return;
}
}
if (qrEventSource) return;
qrEventSource = new EventSource(url);
qrEventSource.onmessage = (e) => {
try {
const evt = JSON.parse(e.data);
if (evt.kind === 'ready') {
document.getElementById('qr-img').src = evt.image;
document.getElementById('qr-hint').textContent = '请用微信扫描上方二维码完成登录';
} else if (evt.kind === 'status') {
document.getElementById('qr-hint').textContent = evt.message;
} else if (evt.kind === 'done') {
stopQrStream();
document.getElementById('qr-area').classList.remove('visible');
setIlinkBadge('connected');
showToast('✅ 微信绑定成功!');
} else if (evt.kind === 'expired') {
stopQrStream();
document.getElementById('qr-img').src = '';
document.getElementById('qr-hint').textContent = '⚠️ 二维码已过期,请点击「重新绑定微信」重试';
showToast('二维码已过期,请重新绑定');
}
} catch(err) {}
};
qrEventSource.onerror = () => {
stopQrStream();
};
}
function stopQrStream() {
if (qrEventSource) {
qrEventSource.close();
qrEventSource = null;
}
}
async function triggerRelogin() {
document.getElementById('relogin-btn').disabled = true;
try {
const r = await fetch('/hub/ilink/relogin', {
method: 'POST',
headers: authHeaders(),
});
const d = await r.json();
if (d.ok) {
showToast('已触发重新登录,请等待二维码…');
setIlinkBadge('logging_in');
} else {
showToast('触发失败: ' + JSON.stringify(d));
}
} catch(e) {
showToast('Error: ' + e.message);
} finally {
setTimeout(() => {
document.getElementById('relogin-btn').disabled = false;
}, 3000);
}
}
function showLoginOverlay() {
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('logout-btn').style.display = 'none';
document.getElementById('login-token-input').value = '';
document.getElementById('login-error').style.display = 'none';
setTimeout(() => document.getElementById('login-token-input').focus(), 50);
}
function hideLoginOverlay() {
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('logout-btn').style.display = '';
}
async function doLogin() {
const input = document.getElementById('login-token-input');
const token = input.value.trim();
if (!token) { input.focus(); return; }
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = '验证中…';
try {
const r = await fetch('/hub/clients', {
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
});
if (r.status === 401 || r.status === 403) {
document.getElementById('login-error').style.display = 'block';
input.value = '';
input.focus();
return;
}
adminToken = token;
sessionStorage.setItem('ilink_admin_token', adminToken);
document.getElementById('login-error').style.display = 'none';
hideLoginOverlay();
loadClients();
loadIlinkStatus();
} catch(e) {
document.getElementById('login-error').textContent = '连接失败,请检查网络';
document.getElementById('login-error').style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = '登录';
}
}
function doLogout() {
adminToken = '';
sessionStorage.removeItem('ilink_admin_token');
showLoginOverlay();
}
document.getElementById('login-token-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin();
});
async function init() {
if (!adminToken) {
const r = await fetch('/hub/clients').catch(() => null);
if (r && r.status === 401) {
showLoginOverlay();
return;
} else if (r && r.ok) {
hideLoginOverlay();
loadClients();
loadIlinkStatus();
return;
}
showLoginOverlay();
return;
}
const r = await fetch('/hub/clients', { headers: authHeaders() }).catch(() => null);
if (r && (r.status === 401 || r.status === 403)) {
adminToken = '';
sessionStorage.removeItem('ilink_admin_token');
showLoginOverlay();
return;
}
hideLoginOverlay();
loadClients();
loadIlinkStatus();
}
init();
setInterval(() => { if (!editingClient) loadClients(); }, 10000);
setInterval(loadIlinkStatus, 5000);
</script>
</body>
</html>