refactor: split node agent and Go control plane

This commit is contained in:
2026-09-11 16:57:31 +08:00
parent 078c22c73b
commit c86ba9c4d7
129 changed files with 654 additions and 77 deletions
+569
View File
@@ -0,0 +1,569 @@
const $ = (id) => document.querySelector(`#${id}`);
let csrf = "";
let refreshTimer = 0;
let busy = false;
let activeView = localStorage.getItem("wxagent-view") || "messages";
let capabilityMap = new Map();
const operationStates = {
Queued: "排队中",
Running: "执行中",
Succeeded: "已完成",
Failed: "失败",
Cancelled: "已取消",
Unconfirmed: "待核对",
};
function showError(error) {
const code = error?.code ? `${error.code}:` : "";
$("error").textContent = `${code}${error?.message || "请求失败"}`;
}
function clearError() {
$("error").textContent = "";
}
async function api(path, init = {}) {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: { Accept: "application/json", ...init.headers },
});
if (!response.ok) {
let body = {};
try {
body = await response.json();
} catch {
/* response may not be JSON */
}
if (response.status === 401) {
$("app").hidden = true;
$("login").hidden = false;
$("connectionBadge").textContent = "未登录";
$("connectionBadge").className = "badge neutral";
}
const error = new Error(body.error?.message || `HTTP ${response.status}`);
error.code = body.error?.code;
error.correlationId =
response.headers.get("X-Correlation-Id") || body.correlationId;
throw error;
}
return response.status === 204 ? null : response.json();
}
async function get(path) {
try {
return { ok: true, value: await api(path) };
} catch (error) {
return { ok: false, error };
}
}
async function mutate(path, body) {
return api(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
"X-WxAgent-Local": "1",
},
body: JSON.stringify(body),
});
}
function setList(id, items, format) {
const target = $(id);
target.replaceChildren(
...items.map((item) => {
const row = document.createElement("div");
row.className = "row";
row.textContent = format(item);
return row;
}),
);
}
function setListMessage(id, message, error = false) {
const target = $(id);
const row = document.createElement("div");
row.className = error ? "row error-row" : "row";
row.textContent = message;
target.replaceChildren(row);
}
function value(result, fallback) {
return result.ok ? result.value : fallback;
}
function friendlyAccount(account) {
const binding = account.binding;
const identity =
binding?.nickname || binding?.wechatId || account.displayName;
return identity
? `${identity} · ${account.bindingStatus || "Unknown"}`
: `数据库账号 ${String(account.accountId).slice(0, 10)}… · ${account.bindingStatus || "Unknown"}`;
}
function renderStatus(result) {
if (!result.ok) {
$("connectionBadge").textContent = "状态读取失败";
$("connectionBadge").className = "badge error";
return;
}
const status = result.value;
$("status").textContent = JSON.stringify(status, null, 2);
$("serviceState").textContent = status.serviceOnline ? "在线" : "离线";
$("wechatState").textContent =
status.wechatAvailable && status.sessionAvailable ? "可用" : "不可用";
$("bindingState").textContent = status.activeAccountBound
? "已绑定"
: "未绑定";
$("modeState").textContent = status.defaultReadOnly ? "只读" : "可写";
const healthy =
status.serviceOnline && status.wechatAvailable && status.sessionAvailable;
$("connectionBadge").textContent = healthy ? "已连接" : "需检查";
$("connectionBadge").className = `badge ${healthy ? "ok" : "warn"}`;
}
function renderCapabilities(result) {
if (!result.ok) {
capabilityMap = new Map();
updateReplyAvailability();
return setListMessage("capabilities", result.error.message, true);
}
capabilityMap = new Map(result.value.map((item) => [item.operation, item]));
setList(
"capabilities",
result.value,
(item) =>
`${item.operation} — ${item.enabled ? "可用" : "禁用"}${item.disabledReason ? `:${item.disabledReason}` : ""}`,
);
updateReplyAvailability();
}
function renderBindings(accounts, targets) {
const accountRows = accounts.map((account) => {
const row = document.createElement("div");
row.className = "row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = friendlyAccount(account);
const meta = document.createElement("div");
meta.className = "row-meta";
meta.textContent = account.binding
? `${account.binding.wechatId || "微信号未读取"} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}`
: `Fingerprint: ${account.accountId}`;
main.append(title, meta);
row.append(main);
if (account.binding) {
const unbind = document.createElement("button");
unbind.className = "secondary";
unbind.textContent = "解绑";
unbind.onclick = async () => {
unbind.disabled = true;
try {
await mutate("/api/v1/accounts/unbind", {
accountId: account.accountId,
});
await refresh();
} catch (error) {
showError(error);
unbind.disabled = false;
}
};
row.append(unbind);
}
return row;
});
$("accounts").replaceChildren(...accountRows);
if (!accountRows.length) setListMessage("accounts", "暂无数据库账号。");
const targetRows = targets.map((target) => {
const row = document.createElement("div");
row.className = "row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = target.wechatId || target.nickname || "身份未读取";
const meta = document.createElement("div");
meta.className = "row-meta";
meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? " · 已绑定" : ""}`;
main.append(title, meta);
row.append(main);
if (!target.isBound) {
const select = document.createElement("select");
select.setAttribute("aria-label", `为 ${target.targetId} 选择数据库账号`);
select.append(
new Option("选择账号", ""),
...accounts
.filter((account) => !account.binding)
.map(
(account) =>
new Option(friendlyAccount(account), account.accountId),
),
);
const bind = document.createElement("button");
bind.textContent = "绑定";
bind.onclick = async () => {
if (!select.value) {
showError(new Error("请先选择数据库账号"));
return;
}
bind.disabled = true;
try {
await mutate("/api/v1/accounts/bind", {
accountId: select.value,
targetId: target.targetId,
});
await refresh();
} catch (error) {
showError(error);
bind.disabled = false;
}
};
row.append(select, bind);
}
return row;
});
$("uiTargets").replaceChildren(...targetRows);
if (!targetRows.length)
setListMessage("uiTargets", "未发现当前交互式会话中的微信主窗口。");
}
function populateAccounts(result) {
if (!result.ok) {
setListMessage("accounts", result.error.message, true);
return [];
}
const accounts = result.value.items || [];
const select = $("accountSelect");
const previous = select.value;
select.replaceChildren(
new Option("选择已绑定账号", ""),
...accounts
.filter((account) => account.binding)
.map(
(account) => new Option(friendlyAccount(account), account.accountId),
),
);
select.value = accounts.some(
(account) => account.accountId === previous && account.binding,
)
? previous
: "";
return accounts;
}
function renderScoped(result, id, format, emptyMessage) {
if (!result.ok) return setListMessage(id, result.error.message, true);
const items = result.value.items || [];
setList(id, items, format);
if (!items.length) setListMessage(id, emptyMessage);
}
function renderReplyTargets(result) {
const sessions = result.ok ? result.value.items || [] : [];
const select = $("replyTarget");
const previous = select.value;
select.replaceChildren(
new Option("选择会话", ""),
...sessions.map(
(session) => new Option(session.name, session.automationId),
),
);
select.value = sessions.some((session) => session.automationId === previous)
? previous
: "";
updateReplyAvailability();
}
function updateReplyAvailability() {
const capability = capabilityMap.get("send-text");
const hasAccount = Boolean($("accountSelect")?.value);
const hasTarget = Boolean($("replyTarget")?.value);
const enabled = Boolean(capability?.enabled && hasAccount && hasTarget);
$("replyTarget").disabled = !capability?.enabled;
$("replyText").disabled = !capability?.enabled;
$("sendReply").disabled = !enabled;
$("replyCapability").textContent = capability?.enabled
? "发送前会再次校验账号和目标"
: capability?.disabledReason || "写能力尚未开放。";
if (capability?.enabled) {
$("replyState").textContent = hasTarget
? "点击发送后创建任务。"
: "请选择目标会话。";
} else {
$("replyState").textContent = "当前不会发送消息。";
}
}
async function sendReply() {
const accountId = $("accountSelect").value;
const targetId = $("replyTarget").value;
const text = $("replyText").value;
if (!accountId || !targetId || !text.trim()) {
showError(new Error("请选择账号、目标会话并输入文本。"));
return;
}
const button = $("sendReply");
button.disabled = true;
clearError();
try {
await api("/api/v1/operations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
"X-WxAgent-Local": "1",
},
body: JSON.stringify({
kind: "send-text",
accountId,
targetId,
text,
idempotencyKey: crypto.randomUUID(),
confirmed: true,
}),
});
$("replyText").value = "";
setView("operations");
await refresh();
} catch (error) {
showError(error);
} finally {
updateReplyAvailability();
}
}
function operationState(state) {
return operationStates[state] || state || "未知";
}
function renderOperations(result) {
if (!result.ok)
return setListMessage("operations", result.error.message, true);
const items = result.value.items || [];
const target = $("operations");
target.replaceChildren(
...items.map((item) => {
const row = document.createElement("div");
row.className = "row task-row";
const main = document.createElement("div");
main.className = "row-main";
const title = document.createElement("div");
title.className = "row-title";
title.textContent = `${item.capability} · ${operationState(item.state)}`;
const meta = document.createElement("div");
meta.className = "row-meta";
const created = item.createdAt
? new Date(item.createdAt).toLocaleString()
: "时间未知";
meta.textContent = `${created} · ${item.stage || "未开始"} · 账号 ${String(item.accountId || "").slice(0, 10)}…`;
main.append(title, meta);
const actions = document.createElement("div");
actions.className = "row-actions";
const detail = document.createElement("button");
detail.className = "secondary";
detail.textContent = "详情";
detail.onclick = async () => {
detail.disabled = true;
try {
$("operation").textContent = JSON.stringify(
await api(`/api/v1/operations/${encodeURIComponent(item.id)}`),
null,
2,
);
} catch (error) {
showError(error);
} finally {
detail.disabled = false;
}
};
actions.append(detail);
if (item.state === "Queued" || item.state === "Running") {
const stop = document.createElement("button");
stop.className = "secondary";
stop.textContent = "停止";
stop.onclick = async () => {
stop.disabled = true;
try {
await api(
`/api/v1/operations/${encodeURIComponent(item.id)}/cancel`,
{
method: "POST",
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
},
);
await refresh();
} catch (error) {
showError(error);
stop.disabled = false;
}
};
actions.append(stop);
}
row.append(main, actions);
return row;
}),
);
if (!items.length) setListMessage("operations", "暂无任务。");
}
function setView(view) {
activeView = view;
localStorage.setItem("wxagent-view", view);
document
.querySelectorAll("[data-view]")
.forEach((button) =>
button.classList.toggle("active", button.dataset.view === view),
);
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
panel.hidden = panel.dataset.viewPanel !== view;
});
}
async function refresh() {
if (busy) return;
busy = true;
clearError();
$("refresh").disabled = true;
$("refresh").textContent = "刷新中…";
try {
const [status, capabilities, accounts, targets, operations] =
await Promise.all([
get("/api/v1/status"),
get("/api/v1/capabilities"),
get("/api/v1/accounts?limit=50"),
get("/api/v1/ui-targets"),
get("/api/v1/operations?limit=50"),
]);
renderStatus(status);
renderCapabilities(capabilities);
renderOperations(operations);
const accountItems = populateAccounts(accounts);
renderBindings(accountItems, value(targets, []));
const accountId = $("accountSelect").value;
if (!accountId) {
const message = "在“连接与设置”确认账号后加载数据。";
setListMessage("sessions", message);
setListMessage("contacts", message);
setListMessage("messages", message);
renderReplyTargets({ ok: true, value: { items: [] } });
return;
}
const query = `&accountId=${encodeURIComponent(accountId)}`;
const [sessions, contacts, messages] = await Promise.all([
get(`/api/v1/sessions?limit=50${query}`),
get(`/api/v1/contacts?limit=50${query}`),
get(
`/api/v1/messages?limit=50&includeContent=${$("content").checked}${query}`,
),
]);
renderScoped(
sessions,
"sessions",
(item) => `${item.name}${item.isCurrent ? "(当前)" : ""}`,
"暂无可见会话。",
);
renderReplyTargets(sessions);
renderScoped(
contacts,
"contacts",
(item) => `${item.displayName || "[未知]"} — ${item.id}`,
"暂无联系人数据或当前凭据尚未开放。",
);
renderScoped(
messages,
"messages",
(item) =>
`${item.type} ${item.sender || ""}: ${item.content ?? item.summary ?? "[正文未授权]"}`,
"暂无可见消息。",
);
} finally {
$("refresh").disabled = false;
$("refresh").textContent = "刷新";
$("lastUpdated").textContent = `更新于 ${new Date().toLocaleTimeString()}`;
busy = false;
}
}
function setAutoRefresh(enabled) {
if (refreshTimer) window.clearInterval(refreshTimer);
refreshTimer = enabled
? window.setInterval(() => {
if (!document.hidden) refresh();
}, 15000)
: 0;
localStorage.setItem("wxagent-auto-refresh", enabled ? "1" : "0");
}
$("loginForm").addEventListener("submit", async (event) => {
event.preventDefault();
const button = $("loginButton");
button.disabled = true;
button.textContent = "连接中…";
clearError();
try {
const response = await api("/api/v1/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: $("token").value }),
});
csrf = response.csrfToken;
$("token").value = "";
$("login").hidden = true;
$("app").hidden = false;
await refresh();
} catch (error) {
showError(error);
} finally {
button.disabled = false;
button.textContent = "连接";
}
});
$("refresh").onclick = refresh;
$("accountSelect").onchange = refresh;
$("content").onchange = refresh;
$("replyTarget").onchange = updateReplyAvailability;
$("replyText").oninput = updateReplyAvailability;
$("sendReply").onclick = sendReply;
$("autoRefresh").checked = localStorage.getItem("wxagent-auto-refresh") === "1";
$("autoRefresh").onchange = (event) => setAutoRefresh(event.target.checked);
document.querySelectorAll("[data-view]").forEach((button) => {
button.onclick = () => setView(button.dataset.view);
});
setView(
document.querySelector(`[data-view="${activeView}"]`)
? activeView
: "messages",
);
if ($("autoRefresh").checked) setAutoRefresh(true);
$("logout").onclick = async () => {
try {
await api("/api/v1/logout", {
method: "POST",
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
});
} finally {
if (refreshTimer) window.clearInterval(refreshTimer);
csrf = "";
$("app").hidden = true;
$("login").hidden = false;
$("connectionBadge").textContent = "未连接";
$("connectionBadge").className = "badge neutral";
}
};
(async () => {
const local = await get("/api/v1/status");
if (local.ok) {
$("login").hidden = true;
$("app").hidden = false;
await refresh();
}
})();
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WxAgent 工作台</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main>
<header class="hero">
<div>
<p class="eyebrow">DESKTOP AGENT</p>
<h1>WxAgent 工作台</h1>
<p class="subtitle">连接 Agent,查看消息、联系人和执行任务。</p>
</div>
<span id="connectionBadge" class="badge neutral">未连接</span>
</header>
<p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login" class="panel login-panel">
<h2>连接 Agent</h2>
<p class="muted">本机打开会自动进入;远程访问请输入连接 Token。Token 不会保存到页面或 URL。</p>
<form id="loginForm" class="login-form">
<label for="token">连接 Token</label>
<div class="input-row"><input id="token" type="password" autocomplete="off" required placeholder="粘贴 Token"><button id="loginButton">连接</button></div>
</form>
</section>
<section id="app" class="app-shell" hidden>
<div class="toolbar panel">
<div class="toolbar-group"><button id="refresh">刷新</button><label class="toggle"><input id="autoRefresh" type="checkbox"> 自动刷新 <span class="muted">15 秒</span></label><span id="lastUpdated" class="muted">尚未刷新</span></div>
<div class="toolbar-group account-picker"><label for="accountSelect">当前账号</label><select id="accountSelect"><option value="">选择已绑定账号</option></select></div>
<button id="logout" class="secondary">退出</button>
</div>
<nav class="workbench-nav panel" aria-label="工作台导航">
<button class="nav-button active" data-view="messages">消息</button>
<button class="nav-button" data-view="contacts">联系人</button>
<button class="nav-button" data-view="broadcast">群发</button>
<button class="nav-button" data-view="operations">任务</button>
<button class="nav-button" data-view="settings">连接与设置</button>
</nav>
<p id="error" class="error" role="alert" aria-live="assertive"></p>
<section class="view-panel" data-view-panel="messages">
<section class="panel intro-panel">
<div><p class="eyebrow">消息</p><h2>从已确认账号开始</h2><p class="muted">先在“连接与设置”确认账号和微信窗口,再加载会话与消息。</p></div>
<label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label>
</section>
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">当前账号</span></div><div id="sessions" class="list"></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="4000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel"><div class="section-heading"><h2>可见消息</h2><span class="muted">只显示当前可读取的数据</span></div><div id="messages" class="list"></div></section>
</section>
<section class="view-panel" data-view-panel="contacts" hidden>
<section class="panel intro-panel"><div><p class="eyebrow">联系人</p><h2>联系人和群</h2><p class="muted">当前阶段只读;群发会在目标确认和任务能力完成后开放。</p></div></section>
<section class="panel"><div class="section-heading"><h2>联系人 / 群</h2><span class="muted">稳定 ID 去重</span></div><div id="contacts" class="list"></div></section>
</section>
<section class="view-panel" data-view-panel="broadcast" hidden>
<section class="panel placeholder"><p class="eyebrow">群发</p><h2>逐项确认后发送</h2><p class="muted">群发任务尚未开放。开放后会显示目标清单、幂等键、逐项结果和停止任务入口;当前不会发送消息。</p><button disabled>暂未开放</button></section>
</section>
<section class="view-panel" data-view-panel="operations" hidden>
<section class="panel"><div class="section-heading"><h2>任务中心</h2><span class="muted">当前登录身份的任务</span></div><div id="operations" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>技术详情</h2><span class="muted">用于反馈和诊断</span></div><pre id="operation"></pre></section>
</section>
<section class="view-panel" data-view-panel="settings" hidden>
<div class="status-grid" aria-live="polite">
<div class="metric panel"><span>服务</span><strong id="serviceState">—</strong></div>
<div class="metric panel"><span>微信 UI</span><strong id="wechatState">—</strong></div>
<div class="metric panel"><span>当前绑定</span><strong id="bindingState">—</strong></div>
<div class="metric panel"><span>默认模式</span><strong id="modeState">只读</strong></div>
</div>
<details class="panel details"><summary>诊断详情</summary><pre id="status" role="status"></pre></details>
<section class="panel"><div class="section-heading"><h2>能力</h2><span class="muted">按当前凭据显示</span></div><div id="capabilities" class="list"></div></section>
<div class="dashboard-grid">
<section class="panel"><div class="section-heading"><h2>账号绑定</h2><span class="muted">数据库账号 ↔ 微信窗口</span></div><div id="accounts" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">自动匹配;失败时手动绑定</span></div><div id="uiTargets" class="list"></div></section>
</div>
</section>
</section>
</main>
<script type="module" src="app.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long