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