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();
}
})();