ai_scheduler/web/assets/js/wx.js

1207 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
* 微信工作台模块(核心)
* - 状态管理 / 生命周期(mount / unmount)
* - 通讯录渲染 / WebSocket / 微信个人信息
* - AI 助手设置
* 子模块:wx_login.js(登录/在线检测)、wx_chat.js(聊天/会话/消息)
* 依赖:core.js(window.Core)
* 用法:Wx.mount(document.getElementById('view-wx'))
* ============================================================ */
(function (global) {
"use strict";
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); };
var state = null;
// WebSocket 连接状态
var wsConn = null; // WebSocket 实例
var wsReconnectTimer = null; // 重连定时器
var wsReconnectDelay = 1000; // 当前重连延迟(ms)
var WS_MAX_RECONNECT = 30000; // 最大重连延迟
/* ==================== 共享命名空间 ==================== */
// 子模块(wx_login.js / wx_chat.js)通过 WxN 访问共享状态和函数
var WxN = global.WxN || (global.WxN = {});
Object.defineProperty(WxN, 'state', {
get: function () { return state; },
set: function (v) { state = v; },
configurable: true
});
WxN.Core = Core;
WxN.$ = $;
WxN.$$ = $$;
/* ==================== brief 缓存(不使用 localStorage) ==================== */
function loadBriefCache() { return {}; }
function saveBriefCache() {}
function clearBriefCache() {}
/** 将上游可能返回数组或字符串的字段统一转为字符串 */
function normalizeStr(val) {
if (val == null) return "";
if (Array.isArray(val)) {
return val.map(function (item) {
// 去掉控制字符(\n \u000b 等)和空白,只保留数字、+、-、空格
return String(item).replace(/[\x00-\x1F\x7F]/g, "").trim();
}).filter(Boolean).join(",");
}
return String(val).replace(/[\x00-\x1F\x7F]/g, "").trim();
}
function freshState(container) {
// 不使用缓存的 wxApp,每次 mount 都从 API 重新获取设备信息
// 避免不同销售之间 appId 混用的问题
var cachedBrief = {};
return {
container: container,
app: null,
online: false,
tab: "conversations", // conversations | friends | labels
contacts: { friends: [], chatrooms: [], ghs: [] },
labels: [], // 标签列表 [{labelId, labelName}] 从 DB 加载
labelMap: {}, // {labelId: labelName} 标签 ID→名称映射
labelFriends: {}, // {labelId: [customer records]} 展开标签下的好友
labelCounts: {}, // {labelId: count} 各标签下好友数量
loadingLabels: {}, // {labelId: true} 正在加载中的标签
expandedLabels: {}, // {labelId: true/false} 哪些标签已展开
syncing: false, // 是否正在全量同步(刷新 forceRefresh 期间为 true)
conversations: [], // 会话列表(从 MongoDB 聚合)
convMsgs: {}, // {wxid: [{direction, msgType, content, createAt}]}
groupMembers: {}, // {chatroomWxid: {memberWxid: {nickName, bigHeadImgUrl, smallHeadImgUrl}}}
brief: cachedBrief, // wxid -> info(从缓存恢复)
briefLoading: false,
briefLoadedCount: 0, // 已加载 brief 的数量
briefTotalCount: 0, // 需要加载的总数
search: "",
active: null, // {wxid, name, avatar, type}
msgs: {}, // {wxid: [{role:'self', text, time}]}
qr: null, // {uuid, img, status, expired}
loginTimer: null,
ai: { sessionId: null, mission: "", versionDesc: "" },
aiTip: null,
wxAdvicer: null // {advicerId, name}
};
}
/* ==================== 挂载 ==================== */
function mount(container) {
// 防御性清理:防止外部未先调用 unmount 导致旧连接/定时器泄漏
unmount();
state = freshState(container);
container.innerHTML = shellHtml();
bindEvents();
renderContacts();
WxN.renderChat();
// 从 URL hash 获取 wx-advicer 值,调用 advicer/info 获取 wx_device_id(即 app_id),再检测在线状态
var advicerId = parseHashAdvicerId();
if (advicerId) {
console.log("[Wx.mount] hash wx-advicer=" + advicerId + ", fetching fresh device info");
WxN.renderLoginCard();
fetchDeviceAndCheck(advicerId);
} else {
console.log("[Wx.mount] no wx-advicer in hash, showing QR login");
WxN.renderLoginCard();
}
return { unmount: unmount };
}
/** 从 URL hash 解析 advicerId(格式:#wx-advicer=123) */
function parseHashAdvicerId() {
var hash = location.hash.replace(/^#/, '');
var match = hash.match(/wx-advicer=(\d+)/);
return match ? Number(match[1]) : 0;
}
/** 通过 API 获取销售的微信设备 ID,然后检测在线状态(不使用缓存) */
function fetchDeviceAndCheck(advicerId) {
Core.api("advicer/info", { advicerId: advicerId }).then(function (info) {
if (!info || !info.wx_device_id) {
// 未绑定微信设备,直接走未登录流程
console.log("[Wx.mount] advicer not found or no wx_device_id, showing QR login");
state.app = null;
WxN.renderLoginCard();
return;
}
// 每次都用最新的 wx_device_id,不使用缓存
state.app = { appId: info.wx_device_id };
// 更新销售姓名
if (state.wxAdvicer) {
state.wxAdvicer.name = info.name || state.wxAdvicer.name;
var nameEl = document.querySelector('#wx-mount-point .ci-name');
if (nameEl) nameEl.textContent = info.name || '';
}
console.log("[Wx.mount] got wxDeviceId=" + info.wx_device_id + ", checking online (no cache)");
// 直接调 checkOnline 检测登录状态
WxN.checkOnline().then(function (online) {
console.log("[Wx.mount] checkOnline result: " + online);
if (online) {
WxN.checkOnlineLoop();
loadProfile().then(function () { WxN.loadConversations(); });
WxN.loadContacts();
loadLabels();
} else {
// 设备不在线,清空登录态,走未登录流程
state.app = null;
WxN.renderLoginCard();
}
});
}).catch(function (e) {
console.error("[Wx.mount] fetchDeviceAndCheck error:", e);
});
}
function unmount() {
if (!state) return;
if (state.loginTimer) clearInterval(state.loginTimer);
disconnectWs();
closeLabelEditor();
WxN.clearDetailCache();
WxN.hideContactDetail();
state = null;
}
function shellHtml() {
return (
'<div class="wx-shell">' +
'<div class="wx-left">' +
'<div id="wxLoginCard"></div>' +
'<div class="wx-search">' +
'<input id="wxSearch" class="input sm" placeholder="搜索联系人 / 群聊">' +
'<button class="btn ghost sm" id="wxRefresh" title="刷新通讯录">⟳</button>' +
"</div>" +
'<div class="wx-contacts" id="wxContacts"></div>' +
"</div>" +
'<div class="wx-right">' +
'<div class="wx-chat-head" id="wxChatHead"></div>' +
'<div id="wxAiTipWrap"></div>' +
'<div class="wx-msgs" id="wxMsgs"></div>' +
'<div class="wx-input-area">' +
'<textarea class="wx-textarea" id="wxText" placeholder="选择左侧联系人后输入消息内容…(Ctrl + Enter 发送)"></textarea>' +
'<div class="wx-send-row">' +
'<span class="wx-hint">发送身份:<span id="wxSelfName" class="c-cyan">-</span></span>' +
'<span class="spacer"></span>' +
'<span class="wx-hint" id="wxSendHint"></span>' +
'<button class="btn success" id="wxAiAsk" title="基于 AI 会话给出话术建议">✦ AI 建议</button>' +
'<button class="btn primary" id="wxSendBtn">发送 (Ctrl+Enter)</button>' +
"</div>" +
"</div>" +
"</div>" +
"</div>"
);
}
function bindEvents() {
$("#wxRefresh").onclick = function () {
// 刷新:全量获取微信好友信息和标签信息,差异更新数据库
clearBriefCache();
state.brief = {};
state.labelFriends = {};
state.labelCounts = {};
state.expandedLabels = {};
state.loadingLabels = {};
state.syncing = true;
WxN.loadContacts(true);
WxN.loadConversations();
loadLabels();
};
$("#wxSearch").oninput = function (e) { state.search = e.target.value.trim(); renderContacts(); };
$("#wxSendBtn").onclick = WxN.doSend;
$("#wxAiAsk").onclick = doAskAI;
$("#wxText").addEventListener("keydown", function (e) {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); WxN.doSend(); }
});
}
/* ==================== 微信个人信息 ==================== */
/** 获取微信个人信息(昵称、头像、wxid 等) */
function loadProfile() {
if (!state || !state.app || !state.app.appId) return Promise.resolve();
console.log("[loadProfile] calling with appId=" + state.app.appId);
return Core.wxApi("personal/getProfile", { appId: state.app.appId })
.then(function (profile) {
console.log("[loadProfile] response:", profile);
if (!profile) return;
// 合并微信个人信息到 state.app
state.app.nickName = profile.nickName || state.app.nickName || "";
state.app.wxid = profile.wxid || state.app.wxid || "";
state.app.headImgUrl = profile.headImgUrl || profile.headImageUrl || state.app.headImgUrl || "";
// 不再持久化到 localStorage,避免不同销售 appId 混用
WxN.renderLoginCard();
// 更新发送身份显示
var selfName = $("#wxSelfName", state.container);
if (selfName) selfName.textContent = state.app.nickName || state.app.wxid || "-";
})
.catch(function (e) { console.error("[loadProfile] error:", e); });
}
/* ==================== 标签 ==================== */
/** 从微信获取标签并同步到 DB,然后更新 state */
function loadLabels() {
if (!state.app || !state.app.appId) return Promise.resolve();
// 从微信获取最新标签,同步到 DB 后直接更新 state
return Core.wxApi("label/list", { appId: state.app.appId })
.then(function (data) {
var wxLabels = (data && data.labelList) || [];
if (!wxLabels.length) return;
var selfWxid = state.app.wxid;
if (!selfWxid) return;
// 同步到 DB
return Core.api("advicer/label/sync", { selfWxid: selfWxid, labels: wxLabels })
.then(function () {
// 同步完成后从 DB 重新加载(保证与 DB 一致)
loadLabelsFromDb();
});
})
.catch(function (e) { console.warn("[loadLabels] error:", e); });
}
/** 从 DB 加载标签列表 */
function loadLabelsFromDb() {
var selfWxid = state.app && state.app.wxid;
if (!selfWxid) return;
return Core.api("advicer/label/list", { selfWxid: selfWxid })
.then(function (list) {
state.labels = (list || []).map(function (l) {
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
});
// 构建 labelId→labelName 映射
state.labelMap = {};
state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; });
renderContacts();
loadLabelCounts();
})
.catch(function (e) { console.warn("[loadLabelsFromDb] error:", e); });
}
/** 获取所有标签下的好友数量统计 */
function loadLabelCounts() {
var selfWxid = state.app && state.app.wxid;
if (!selfWxid || !state.labels.length) return;
state.labels.forEach(function (l) {
var lid = String(l.labelId);
Core.api("advicer/customer/list", { selfWxid: selfWxid, labelId: lid, page: 1, page_size: 0 })
.then(function (list) {
state.labelCounts[lid] = (list || []).length;
renderContacts();
})
.catch(function () {});
});
}
/** 加载某个标签下的好友(从 DB 查询 label_list 包含该 labelId 的记录) */
function loadLabelFriends(labelId) {
var selfWxid = state.app && state.app.wxid;
if (!selfWxid) return Promise.resolve();
state.loadingLabels[String(labelId)] = true;
return Core.api("advicer/customer/list", { selfWxid: selfWxid, labelId: String(labelId), page: 1, page_size: 0 })
.then(function (list) {
state.labelFriends[String(labelId)] = list || [];
state.labelCounts[String(labelId)] = (list || []).length;
delete state.loadingLabels[String(labelId)];
renderContacts();
})
.catch(function (e) {
delete state.loadingLabels[String(labelId)];
console.warn("[loadLabelFriends] error:", e);
});
}
/* ==================== 通讯录 ==================== */
function loadContacts(forceRefresh) {
if (!state.app || !state.app.appId) { Core.toast("请先登录微信", "err"); return; }
Core.loading(true, "拉取通讯录…");
Core.wxApi("contacts/fetchContactsList", { appId: state.app.appId })
.then(function (data) {
Core.loading(false);
state.contacts = {
friends: (data && data.friends) || [],
chatrooms: (data && data.chatrooms) || [],
ghs: (data && data.ghs) || []
};
renderContacts();
// 好友信息统一由 diff 接口提供:库中已有的从 list 填充 brief,差集部分调 getBriefInfo 补充后入库
syncCustomerDiff(!!forceRefresh);
})
.catch(function (e) { Core.loading(false); Core.toast("拉取通讯录失败:" + e.message, "err"); });
}
/** 仅拉取群聊的 brief info */
function loadBriefInfoForChatrooms() {
var chatrooms = state.contacts.chatrooms;
if (!chatrooms.length) return;
var batches = [];
for (var i = 0; i < chatrooms.length; i += 20) batches.push(chatrooms.slice(i, i + 20));
var chain = Promise.resolve();
batches.forEach(function (batch) {
chain = chain.then(function () {
return getBriefInfoWithRetry(batch)
.then(function (list) {
(list || []).forEach(function (info) { if (info && info.userName) state.brief[info.userName] = info; });
renderContacts();
})
.catch(function () {});
});
});
}
/** getBriefInfo 带重试(最多 3 次,间隔 2 秒) */
function getBriefInfoWithRetry(wxids, retries) {
retries = retries || 3;
return Core.wxApi("contacts/getBriefInfo", { appId: state.app.appId, wxids: wxids })
.catch(function (e) {
if (retries <= 1) throw e;
console.warn("[getBriefInfo] retrying, " + (retries - 1) + " attempts left");
return new Promise(function (resolve) { setTimeout(resolve, 2000); })
.then(function () { return getBriefInfoWithRetry(wxids, retries - 1); });
});
}
/** 同步好友差集到 ai_advice_customer 表
* forceRefresh=true(刷新按钮):获取所有好友最新 brief,与 DB 比对,只更新有变化的 + 新增缺失
* forceRefresh=false(正常登录):只新增缺失好友,不更新已有记录 */
function syncCustomerDiff(forceRefresh) {
var selfWxid = state.app && state.app.wxid;
if (!selfWxid || !state.contacts.friends.length) return;
Core.api("advicer/customer/diff", { selfWxid: selfWxid, userNames: state.contacts.friends })
.then(function (res) {
if (!res) return;
var existingList = res.list || [];
var diff = res.diff || [];
// 从 diff 响应中直接加载标签(进入工作台时一并获取)
if (res.labels) {
state.labels = res.labels.map(function (l) {
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
});
state.labelMap = {};
state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; });
loadLabelCounts();
}
// 将库中已有的好友记录写入 brief
existingList.forEach(function (item) {
if (item.user_name && !state.brief[item.user_name]) {
state.brief[item.user_name] = {
userName: item.user_name,
nickName: item.nick_name,
remark: item.remark,
sex: item.sex,
bigHeadImgUrl: item.big_head_img_url,
smallHeadImgUrl: item.small_head_img_url,
alias: item.alias,
signature: item.signature,
province: item.province,
city: item.city,
labelList: item.label_list || ""
};
}
});
renderContacts();
// 辅助函数:新增缺失好友到 DB
function addNewFriends() {
if (!diff.length) { state.syncing = false; renderContacts(); return Promise.resolve(); }
console.log("[syncCustomerDiff] missing " + diff.length + " friends, fetching brief info");
var batches = [];
for (var j = 0; j < diff.length; j += 20) batches.push(diff.slice(j, j + 20));
var allInfo = [];
var chain = Promise.resolve();
batches.forEach(function (batch) {
chain = chain.then(function () {
return getBriefInfoWithRetry(batch)
.then(function (list) {
(list || []).forEach(function (info) {
if (info && info.userName) {
allInfo.push(info);
state.brief[info.userName] = info;
}
});
});
});
});
return chain.then(function () {
if (!allInfo.length) return;
var records = allInfo.map(function (info) {
return {
selfWxid: selfWxid, userName: info.userName || "",
nickName: info.nickName || "", pyInitial: info.pyInitial || "",
quanPin: info.quanPin || "", sex: info.sex || 0,
remark: info.remark || "", remarkPyInitial: info.remarkPyInitial || "",
remarkQuanPin: info.remarkQuanPin || "", signature: info.signature || "",
alias: info.alias || "", snsBgImg: info.snsBgImg || "",
country: info.country || "", bigHeadImgUrl: info.bigHeadImgUrl || "",
smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "",
cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "",
province: info.province || "", city: info.city || "",
phoneNumList: normalizeStr(info.phoneNumList)
};
});
Core.api("advicer/customer/batch_add", { list: records })
.then(function () { console.log("[syncCustomerDiff] batch_add " + records.length + " new friends"); })
.catch(function (e) { console.warn("[syncCustomerDiff] batch_add failed:", e); });
state.syncing = false;
renderContacts();
});
}
if (forceRefresh) {
// 刷新流程:获取所有已有好友的最新 brief,与 DB 比对,只更新有变化的
var existingNames = existingList.map(function (e) { return e.user_name; }).filter(Boolean);
if (!existingNames.length) { addNewFriends(); return; }
var existingBatches = [];
for (var i = 0; i < existingNames.length; i += 20) existingBatches.push(existingNames.slice(i, i + 20));
var existingChain = Promise.resolve();
var latestInfos = [];
existingBatches.forEach(function (batch) {
existingChain = existingChain.then(function () {
return getBriefInfoWithRetry(batch)
.then(function (list) {
(list || []).forEach(function (info) {
if (info && info.userName) {
latestInfos.push(info);
state.brief[info.userName] = info;
}
});
})
.catch(function () {});
});
});
existingChain.then(function () {
// 与 DB 比对,找出有变化的记录
var dbMap = {};
existingList.forEach(function (e) { dbMap[e.user_name] = e; });
var changedRecords = [];
latestInfos.forEach(function (info) {
var db = dbMap[info.userName];
if (!db) return;
if ((info.labelList || "") !== (db.label_list || "") ||
(info.nickName || "") !== (db.nick_name || "") ||
(info.remark || "") !== (db.remark || "") ||
(info.bigHeadImgUrl || "") !== (db.big_head_img_url || "") ||
(info.smallHeadImgUrl || "") !== (db.small_head_img_url || "") ||
(info.sex || 0) !== (db.sex || 0)) {
changedRecords.push({
selfWxid: selfWxid, userName: info.userName || "",
nickName: info.nickName || "", pyInitial: info.pyInitial || "",
quanPin: info.quanPin || "", sex: info.sex || 0,
remark: info.remark || "", remarkPyInitial: info.remarkPyInitial || "",
remarkQuanPin: info.remarkQuanPin || "", signature: info.signature || "",
alias: info.alias || "", snsBgImg: info.snsBgImg || "",
country: info.country || "", bigHeadImgUrl: info.bigHeadImgUrl || "",
smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "",
cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "",
province: info.province || "", city: info.city || "",
phoneNumList: normalizeStr(info.phoneNumList)
});
}
});
if (changedRecords.length) {
console.log("[syncCustomerDiff] batch_update " + changedRecords.length + " changed friends (total " + latestInfos.length + ")");
Core.api("advicer/customer/batch_update", { list: changedRecords })
.catch(function (e) { console.warn("[syncCustomerDiff] batch_update failed:", e); });
} else {
console.log("[syncCustomerDiff] no changes detected in " + latestInfos.length + " existing friends");
}
renderContacts();
// 然后新增缺失好友
addNewFriends();
});
} else {
// 正常流程:只新增缺失好友
addNewFriends();
}
})
.catch(function (e) { state.syncing = false; console.warn("[syncCustomerDiff] diff failed:", e); });
}
function contactDisplay(wxid) {
var info = state.brief[wxid] || {};
var name = info.remark || info.nickName || wxid;
return { name: name, avatar: info.bigHeadImgUrl || info.smallHeadImgUrl || "" };
}
/** 获取好友的标签 HTML(小标签展示,全部标签) */
function contactLabelsHtml(wxid) {
var info = state.brief[wxid];
if (!info || !info.labelList) return "";
var ids = String(info.labelList).split(",").filter(Boolean);
if (!ids.length) return "";
var tags = [];
ids.forEach(function (id) {
var name = state.labelMap[id];
if (name) tags.push(name);
});
if (!tags.length) return "";
return '<div class="ci-labels">' + tags.map(function (t) {
return '<span class="ci-label">' + Core.esc(t) + '</span>';
}).join("") + '</div>';
}
/** 获取好友的已托管标签 HTML(仅显示 hosting=1 的标签) */
function hostingLabelsHtml(wxid) {
var info = state.brief[wxid];
if (!info || !info.labelList) return "";
var ids = String(info.labelList).split(",").filter(Boolean);
if (!ids.length) return "";
var tags = [];
ids.forEach(function (id) {
// 查找该标签是否为托管标签
for (var i = 0; i < state.labels.length; i++) {
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
tags.push(state.labels[i].labelName);
break;
}
}
});
if (!tags.length) return "";
return '<div class="ci-labels">' + tags.map(function (t) {
return '<span class="ci-label ci-label-hosting">' + Core.esc(t) + '</span>';
}).join("") + '</div>';
}
/** 获取好友所属的托管标签 ID 列表 */
function getHostingLabelIds(wxid) {
var info = state.brief[wxid];
if (!info || !info.labelList) return [];
var ids = String(info.labelList).split(",").filter(Boolean);
var hostingIds = [];
ids.forEach(function (id) {
for (var i = 0; i < state.labels.length; i++) {
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
hostingIds.push(id);
break;
}
}
});
return hostingIds;
}
function renderContacts() {
if (WxN.hideContactDetail) WxN.hideContactDetail();
var box = $("#wxContacts", state.container);
if (!state.app || !state.app.appId) {
box.innerHTML = '<div class="empty" style="padding:30px 14px"><div class="big">◇</div>登录微信后自动拉取通讯录</div>';
return;
}
var kw = state.search.toLowerCase();
var tabs =
'<div class="seg" style="margin:10px 12px 4px">' +
'<div class="seg-item ' + (state.tab === "conversations" ? "active" : "") + '" data-tab="conversations">会话 ' + state.conversations.length + "</div>" +
'<div class="seg-item ' + (state.tab === "friends" ? "active" : "") + '" data-tab="friends">好友</div>' +
'<div class="seg-item ' + (state.tab === "labels" ? "active" : "") + '" data-tab="labels">标签 ' + state.labels.length + "</div>" +
"</div>";
var items;
if (state.tab === "conversations") {
// 会话列表
var convList = state.conversations;
if (kw) {
convList = convList.filter(function (c) {
var d = WxN.conversationDisplay(c.wxid);
return d.name.toLowerCase().indexOf(kw) >= 0 || c.wxid.toLowerCase().indexOf(kw) >= 0;
});
}
if (!convList.length) {
items = '<div class="empty" style="padding:30px 14px"><div class="big">✉</div>' + (kw ? "无匹配会话" : "暂无会话,收到消息后自动显示") + "</div>";
} else {
items = convList.map(function (c) {
var d = WxN.conversationDisplay(c.wxid);
var active = state.active && state.active.wxid === c.wxid ? " active" : "";
var timeStr = c.lastMsgTime ? WxN.timeAgo(c.lastMsgTime * 1000) : "";
var dirIcon = c.lastDirection === "customer" ? "←" : c.lastDirection === "self" ? "→" : "";
var unreadBadge = (c.unreadCount > 0)
? '<span class="conv-unread">' + (c.unreadCount > 99 ? "99+" : c.unreadCount) + '</span>'
: '';
return '<div class="contact-item conv-item' + active + '" data-wxid="' + Core.esc(c.wxid) + '">' +
'<div class="conv-avatar-wrap">' +
(d.avatar
? '<img class="wx-avatar" style="width:36px;height:36px" src="' + Core.esc(d.avatar) + '" alt="" onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'wx-avatar\',style:\'width:36px;height:36px\',textContent:\'?\'}))">'
: '<div class="wx-avatar" style="width:36px;height:36px">' + Core.esc(Core.initial(d.name)) + "</div>") +
unreadBadge + '</div>' +
'<div class="ci-main"><div class="ci-name-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
(c.isGroup ? '<span class="ci-tag">群</span>' : '') +
hostingLabelsHtml(c.wxid) +
'<span class="ci-time">' + Core.esc(timeStr) + "</span></div>" +
'<div class="ci-sub">' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + '</div></div>' +
(!c.isGroup ? '<button class="ci-edit-label-btn" data-wxid="' + Core.esc(c.wxid) + '" title="编辑标签">标签</button>' : '') +
'</div>';
}).join("");
}
} else if (state.tab === "labels") {
// 标签列表:每个标签一行,点击展开显示该标签下的好友
var labelList = state.labels;
if (kw) {
labelList = labelList.filter(function (l) {
return (l.labelName || "").toLowerCase().indexOf(kw) >= 0;
});
}
if (!labelList.length) {
items = '<div class="empty" style="padding:30px 14px"><div class="big">🏷</div>' + (kw ? "无匹配标签" : "暂无标签") + "</div>";
} else {
items = labelList.map(function (l) {
var lid = String(l.labelId);
var expanded = !!state.expandedLabels[lid];
var arrow = expanded ? "▾" : "▸";
var friends = state.labelFriends[lid] || [];
var count = state.labelCounts[lid];
var countSpan = (count != null)
? ' <span class="c-dim fs-11">(' + count + ')</span>'
: ' <span class="c-dim fs-11">…</span>';
var hostingCls = l.hosting ? ' hosting' : '';
var hostingBtn = '<button class="label-hosting-btn' + hostingCls + '" data-label-id="' + Core.esc(lid) + '" data-hosting="' + (l.hosting ? 0 : 1) + '" title="' + (l.hosting ? '取消托管' : '开启托管') + '">' + (l.hosting ? '托管中' : '托管') + '</button>';
var html = '<div class="label-row" data-label-id="' + Core.esc(lid) + '">' +
'<span class="label-arrow">' + arrow + '</span>' +
'<span class="label-name">' + Core.esc(l.labelName) + '</span>' +
countSpan +
'<span class="spacer"></span>' +
hostingBtn + '</div>';
if (expanded) {
var isLoading = !!state.loadingLabels[lid];
if (isLoading) {
html += '<div class="label-loading fs-12 c-dim" style="padding:8px 16px"><span class="spin"></span> 加载中…</div>';
} else if (friends.length) {
html += friends.map(function (f) {
var d = contactDisplay(f.user_name);
if (!d.avatar && f.big_head_img_url) d.avatar = f.big_head_img_url;
if (!d.name || d.name === f.user_name) d.name = f.nick_name || f.remark || f.user_name;
return '<div class="contact-item label-friend" data-wxid="' + Core.esc(f.user_name) + '">' +
(d.avatar
? '<img class="wx-avatar" style="width:32px;height:32px" src="' + Core.esc(d.avatar) + '" alt="">'
: '<div class="wx-avatar" style="width:32px;height:32px">' + Core.esc(Core.initial(d.name)) + '</div>') +
'<div class="ci-main"><div class="ci-name">' + Core.esc(d.name) + '</div>' +
'<div class="ci-sub mono">' + Core.esc(f.user_name) + '</div></div></div>';
}).join("");
} else {
html += '<div class="label-loading fs-12 c-dim" style="padding:8px 16px">加载中…</div>';
}
}
return html;
}).join("");
}
} else {
// 好友列表
var list = state.contacts.friends;
if (kw) {
list = list.filter(function (w) {
var d = contactDisplay(w);
return d.name.toLowerCase().indexOf(kw) >= 0 || w.toLowerCase().indexOf(kw) >= 0;
});
}
if (state.briefLoading && !Object.keys(state.brief).length) {
items = '<div class="empty" style="padding:26px"><span class="spin"></span><div class="mt-1 fs-12">正在加载联系人信息…</div></div>';
} else if (!list.length) {
items = '<div class="empty" style="padding:30px 14px"><div class="big">☰</div>' + (kw ? "无匹配联系人" : "暂无联系人,点击右上角 ⟳ 刷新") + "</div>";
} else {
items = list.map(function (w) {
var d = contactDisplay(w);
var active = state.active && state.active.wxid === w ? " active" : "";
return '<div class="contact-item' + active + '" data-wxid="' + Core.esc(w) + '">' +
(d.avatar
? '<img class="wx-avatar" style="width:36px;height:36px" src="' + Core.esc(d.avatar) + '" alt="" onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'wx-avatar\',style:\'width:36px;height:36px\',textContent:\'?\'}))">'
: '<div class="wx-avatar" style="width:36px;height:36px">' + Core.esc(Core.initial(d.name)) + "</div>") +
'<div class="ci-main"><div class="ci-name-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
hostingLabelsHtml(w) + '</div>' +
'<div class="ci-sub mono">' + Core.esc(w) + "</div></div>" +
'<button class="ci-edit-label-btn" data-wxid="' + Core.esc(w) + '" title="编辑标签">标签</button>' +
'</div>';
}).join("");
}
}
// 加载中进度提示
var progressHint = '';
if (state.syncing) {
progressHint = '<div class="fs-11 c-dim" style="text-align:center;padding:4px 0"><span class="spin"></span> 正在同步好友信息…</div>';
} else if (state.briefLoading && state.briefTotalCount > 0) {
progressHint = '<div class="fs-11 c-dim" style="text-align:center;padding:4px 0">加载联系人信息 ' +
Math.min(state.briefLoadedCount, state.briefTotalCount) + ' / ' + state.briefTotalCount + '</div>';
}
box.innerHTML = tabs + items + progressHint;
$$(".seg-item", box).forEach(function (s) {
s.onclick = function () {
state.tab = s.dataset.tab;
if (state.tab === "conversations") WxN.loadConversations();
if (state.tab === "labels") loadLabelCounts();
renderContacts();
};
});
// 标签行点击展开/收起
$$(".label-row", box).forEach(function (row) {
row.onclick = function (e) {
// 点击托管按钮不触发展开/收起
if (e.target.classList.contains('label-hosting-btn')) return;
var lid = row.dataset.labelId;
if (state.expandedLabels[lid]) {
// 收起
delete state.expandedLabels[lid];
renderContacts();
} else {
// 展开
state.expandedLabels[lid] = true;
state.labelFriends[lid] = [];
renderContacts();
loadLabelFriends(lid);
}
};
});
// 托管按钮点击
$$(".label-hosting-btn", box).forEach(function (btn) {
btn.onclick = function (e) {
e.stopPropagation();
var lid = Number(btn.dataset.labelId);
var newHosting = Number(btn.dataset.hosting);
var selfWxid = state.app && state.app.wxid;
if (!selfWxid) return;
btn.disabled = true;
Core.api("advicer/label/hosting", { selfWxid: selfWxid, labelId: lid, hosting: newHosting })
.then(function () {
// 更新 state.labels 中对应标签的 hosting 状态
state.labels.forEach(function (l) {
if (l.labelId === lid) l.hosting = newHosting;
});
Core.toast(newHosting ? "已开启托管" : "已取消托管", "ok");
renderContacts();
})
.catch(function (e) {
Core.toast("操作失败:" + e.message, "err");
btn.disabled = false;
});
};
});
$$(".contact-item", box).forEach(function (c) {
c.onclick = function () {
var wxid = c.dataset.wxid;
if (state.tab === "conversations") {
WxN.openConversationChat(wxid);
} else {
WxN.openChat(wxid);
}
};
// 悬浮显示详情气泡(好友 / 会话 / 标签内好友)
if (state.tab === "friends" || state.tab === "conversations" || c.classList.contains("label-friend")) {
c.onmouseenter = function (e) { WxN.showContactDetail(c.dataset.wxid, c, e); };
c.onmouseleave = function () { WxN.hideContactDetail(); };
}
});
// 编辑标签按钮点击
$$(".ci-edit-label-btn", box).forEach(function (btn) {
btn.onclick = function (e) {
e.stopPropagation();
openLabelEditor(btn.dataset.wxid, btn);
};
});
}
/* ==================== 标签编辑弹窗 ==================== */
var _labelEditorPopup = null;
function openLabelEditor(wxid, anchorEl) {
closeLabelEditor();
var s = state;
var info = s.brief[wxid] || {};
var currentLabels = (info.labelList || "").split(",").filter(Boolean);
var allLabels = s.labels;
if (!allLabels.length) { Core.toast("暂无标签,请先在标签 tab 同步", "warn"); return; }
var popup = document.createElement('div');
popup.className = 'wx-label-editor-popup';
popup.style.position = 'fixed';
popup.style.zIndex = '9999';
// 视口边界检测
var popupWidth = 260;
var rect = anchorEl.getBoundingClientRect();
var leftPos = rect.right + 6;
if (leftPos + popupWidth > window.innerWidth - 10) {
leftPos = rect.left - popupWidth - 6;
}
if (leftPos < 10) leftPos = 10;
var topPos = rect.top;
if (topPos + 350 > window.innerHeight) {
topPos = Math.max(10, window.innerHeight - 360);
}
popup.style.left = leftPos + 'px';
popup.style.top = topPos + 'px';
// 渲染标签列表
var html = '<div class="wx-le-head">编辑标签</div>';
html += '<div class="wx-le-body">';
allLabels.forEach(function (l) {
var lid = String(l.labelId);
var checked = currentLabels.indexOf(lid) >= 0 ? ' checked' : '';
var checkedCls = checked ? ' wx-le-checked' : '';
html += '<label class="wx-le-item' + checkedCls + '" data-label-id="' + Core.esc(lid) + '">' +
'<input type="checkbox" value="' + Core.esc(lid) + '"' + checked + '>' +
'<span class="wx-le-name">' + Core.esc(l.labelName) + '</span>' +
(l.hosting ? '<span class="wx-le-hosting-tag">托管</span>' : '') +
'</label>';
});
html += '</div>';
html += '<div class="wx-le-foot">' +
'<button class="btn ghost sm wx-le-cancel">取消</button>' +
'<button class="btn primary sm wx-le-save">保存</button>' +
'</div>';
popup.innerHTML = html;
document.body.appendChild(popup);
_labelEditorPopup = popup;
// checkbox 变化时更新样式
$$(".wx-le-item input", popup).forEach(function (cb) {
cb.onchange = function () {
var label = cb.closest('.wx-le-item');
if (cb.checked) label.classList.add('wx-le-checked');
else label.classList.remove('wx-le-checked');
};
});
// 取消
$(".wx-le-cancel", popup).onclick = function () { closeLabelEditor(); };
// 保存
$(".wx-le-save", popup).onclick = function () {
var newLabelIds = [];
$$(".wx-le-item input:checked", popup).forEach(function (cb) {
newLabelIds.push(cb.value);
});
var selfWxid = s.app && s.app.wxid;
if (!selfWxid) return;
var proj = Core.store(Core.KEYS.project);
if (!proj || !proj.projectId) { Core.toast("请先选择项目", "err"); return; }
var saveBtn = $(".wx-le-save", popup);
saveBtn.disabled = true;
saveBtn.textContent = "保存中…";
// 一次调用后端接口:内部完成 wx API 调用 + DB 更新
Core.api("advicer/customer/update_labels", {
projectId: proj.projectId,
appId: s.app.appId,
selfWxid: selfWxid,
userName: wxid,
labelList: newLabelIds.join(",")
}).then(function () {
// 更新本地 brief 中的 labelList
if (s.brief[wxid]) {
s.brief[wxid].labelList = newLabelIds.join(",");
}
Core.toast("标签已更新", "ok");
closeLabelEditor();
renderContacts();
}).catch(function (e) {
Core.toast("保存失败:" + e.message, "err");
saveBtn.disabled = false;
saveBtn.textContent = "保存";
});
};
// 点击外部关闭
setTimeout(function () {
document.addEventListener('mousedown', _labelEditorOutsideClick, true);
}, 0);
}
function _labelEditorOutsideClick(e) {
if (_labelEditorPopup && !_labelEditorPopup.contains(e.target) && !e.target.classList.contains('ci-edit-label-btn')) {
closeLabelEditor();
}
}
function closeLabelEditor() {
document.removeEventListener('mousedown', _labelEditorOutsideClick, true);
if (_labelEditorPopup && _labelEditorPopup.parentNode) {
_labelEditorPopup.parentNode.removeChild(_labelEditorPopup);
}
_labelEditorPopup = null;
}
/* ==================== WebSocket 实时通信 ==================== */
function connectWs() {
if (!state || !state.app || !state.app.appId) {
console.warn("[WS] connectWs skipped: appId missing");
return;
}
console.log("[WS] connecting, appId=" + state.app.appId);
disconnectWs(); // 先断开已有连接
var token = Core.store(Core.KEYS.token);
if (!token) { console.warn("[WS] connectWs skipped: token missing"); return; }
var protocol = location.protocol === "https:" ? "wss:" : "ws:";
var url = protocol + "//" + location.host + "/api/v1/advicer/ws?token=" + encodeURIComponent(token);
try {
wsConn = new WebSocket(url);
} catch (e) {
scheduleWsReconnect();
return;
}
wsConn.onopen = function () {
console.log("[WS] connected, subscribing appId=" + state.app.appId);
wsReconnectDelay = 1000; // 重置重连延迟
// 订阅当前 appId 的事件
wsConn.send(JSON.stringify({ action: "subscribe", appId: state.app.appId }));
// 启动心跳(每 20 秒发一次 ping)
startWsHeartbeat();
};
wsConn.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
console.log("[WS] received event:", msg.event, msg.data);
handleWsEvent(msg);
} catch (err) { console.warn("[WS] parse error:", err); }
};
wsConn.onclose = function () {
console.log("[WS] connection closed");
stopWsHeartbeat();
wsConn = null;
scheduleWsReconnect();
};
wsConn.onerror = function () {
// onclose 会紧随触发,无需额外处理
};
}
function disconnectWs() {
stopWsHeartbeat();
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
if (wsConn) {
wsConn.onclose = null; // 避免触发重连
wsConn.close();
wsConn = null;
}
wsReconnectDelay = 1000;
}
function scheduleWsReconnect() {
if (!state || !state.app || !state.app.appId) return;
if (wsReconnectTimer) return;
wsReconnectTimer = setTimeout(function () {
wsReconnectTimer = null;
connectWs();
}, wsReconnectDelay);
wsReconnectDelay = Math.min(wsReconnectDelay * 2, WS_MAX_RECONNECT);
}
var wsHeartbeatTimer = null;
function startWsHeartbeat() {
stopWsHeartbeat();
wsHeartbeatTimer = setInterval(function () {
if (wsConn && wsConn.readyState === WebSocket.OPEN) {
wsConn.send(JSON.stringify({ action: "ping" }));
}
}, 20000);
}
function stopWsHeartbeat() {
if (wsHeartbeatTimer) { clearInterval(wsHeartbeatTimer); wsHeartbeatTimer = null; }
}
function handleWsEvent(msg) {
if (!msg || !msg.event || !state) return;
switch (msg.event) {
case "new_message":
WxN.handleNewMessage(msg.data);
break;
case "online_status":
WxN.handleOnlineStatus(msg.data);
break;
}
}
/* ==================== AI 助手 ==================== */
function openAiConfig() {
var proj = Core.store(Core.KEYS.project);
if (!proj || !proj.projectId) {
Core.toast("请先在「项目管理」进入一个项目,AI 会话基于项目的销售版本/技巧注册", "err");
return;
}
var projectId = proj.projectId;
var m = Core.modal({
title: "⚙ AI 助手设置(销售话术演练会话)",
width: "wide",
body: '<div class="empty" style="padding:20px"><span class="spin"></span></div>'
});
Core.api("advicer/list", { projectId: projectId }).then(function (advicers) {
advicers = advicers || [];
var advOptions = advicers.map(function (a) {
return '<option value="' + a.advicer_id + '">#' + a.advicer_id + " " + Core.esc(a.name || "") + "</option>";
}).join("");
m.body.innerHTML =
'<div class="card-sub" style="margin-top:0">选择销售版本 + 聊天技巧 + 客户,注册一个 AI 演练会话;在聊天窗口点击「AI 建议」即可生成回复话术。</div>' +
'<div class="field"><label class="label">销售人员<span class="req">*</span></label><select id="ai-advicer" class="select">' + advOptions + "</select></div>" +
'<div class="grid cols-2">' +
'<div class="field"><label class="label">销售版本<span class="req">*</span></label><select id="ai-version" class="select"><option>加载中…</option></select></div>' +
'<div class="field"><label class="label">聊天技巧<span class="req">*</span></label><select id="ai-skill" class="select"><option>加载中…</option></select></div>' +
"</div>" +
'<div class="field"><label class="label">客户(可选)</label><select id="ai-client" class="select"><option value="">不指定客户</option></select></div>' +
'<div class="field"><label class="label">任务 Mission<span class="req">*</span></label><textarea id="ai-mission" class="textarea" placeholder="本轮对话任务,如:了解客户预算与购房目的,邀约到访">' + Core.esc(state.ai.mission || "") + "</textarea></div>" +
'<div class="row"><span class="badge ' + (state.ai.sessionId ? "green" : "gray") + '" id="ai-session-badge">' + (state.ai.sessionId ? "会话已就绪(重注册将覆盖)" : "尚未注册会话") + "</span></div>";
$(".modal-foot", m.root).innerHTML = '<button class="btn ghost mf-cancel">关闭</button><button class="btn primary mf-regis">注册 AI 会话</button>';
$(".mf-cancel", m.root).onclick = m.close;
if (!advicers.length) {
m.body.innerHTML += '<div class="mt-1 fs-12 c-warn">当前项目暂无销售人员,请先到「数据中心 → 销售人员」新增。</div>';
} else {
loadVersions(advicers[0].advicer_id);
}
loadSkills();
loadClients();
$("#ai-advicer", m.root).onchange = function () {
loadVersions(this.value);
};
function loadVersions(advicerId) {
Core.api("advicer/version/list", { advicerId: Number(advicerId) }).then(function (list) {
list = list || [];
$("#ai-version", m.root).innerHTML = list.length
? list.map(function (v) { return '<option value="' + v.id + '">' + Core.esc(v.versionDesc || v.id) + "</option>"; }).join("")
: '<option value="">无版本(请到数据中心创建)</option>';
}).catch(function (e) { Core.toast(e.message, "err"); });
}
function loadSkills() {
Core.api("skill/list", { projectId: projectId }).then(function (list) {
list = list || [];
$("#ai-skill", m.root).innerHTML = list.length
? list.map(function (s) { return '<option value="' + s.id + '">' + Core.esc((s.desc || "技巧") + " · " + s.id.slice(-6)) + "</option>"; }).join("")
: '<option value="">无技巧(请到数据中心创建)</option>';
}).catch(function (e) { Core.toast(e.message, "err"); });
}
function loadClients() {
Core.api("client/list", { projectId: projectId }).then(function (list) {
list = list || [];
$("#ai-client", m.root).innerHTML = '<option value="">不指定客户</option>' +
list.map(function (c) {
var name = (c.personalInfo && c.personalInfo.name) || "客户";
return '<option value="' + c.id + '">' + Core.esc(name) + " · " + c.id.slice(-6) + "</option>";
}).join("");
}).catch(function () {});
}
$(".mf-regis", m.root).onclick = function () {
var versionId = $("#ai-version", m.root).value;
var skillId = $("#ai-skill", m.root).value;
var mission = $("#ai-mission", m.root).value.trim();
if (!versionId) { Core.toast("请选择销售版本", "err"); return; }
if (!skillId) { Core.toast("请选择聊天技巧", "err"); return; }
if (!mission) { Core.toast("请填写任务 Mission", "err"); return; }
Core.loading(true, "注册 AI 会话…");
Core.api("chat/regis", {
advicerVersionId: versionId,
clientId: $("#ai-client", m.root).value || "",
talkSkillId: skillId,
mission: mission
}).then(function (sessionId) {
Core.loading(false);
state.ai.sessionId = sessionId;
state.ai.mission = mission;
Core.toast("AI 会话注册成功", "ok");
m.close();
WxN.renderChatHead();
}).catch(function (e) { Core.loading(false); Core.toast("注册失败:" + e.message, "err"); });
};
}).catch(function (e) {
m.body.innerHTML = '<div class="empty">' + Core.esc(e.message) + "</div>";
});
}
function clearAiTip() {
state.aiTip = null;
var wrap = $("#wxAiTipWrap", state.container);
if (wrap) wrap.innerHTML = "";
}
function renderAiTip(tip) {
state.aiTip = tip;
var wrap = $("#wxAiTipWrap", state.container);
var badge = tip.missionStatus
? '<span class="badge ' + (tip.missionStatus === "completed" ? "green" : tip.missionStatus === "fail" ? "red" : "yellow") + '">' + Core.esc(tip.missionStatus) + "</span>"
: "";
wrap.innerHTML =
'<div class="ai-tip">' +
'<div class="ai-head">✦ AI 话术建议 ' + badge + '<span class="spacer"></span><span class="fs-12 c-dim">' + Core.esc(tip.missionDesc || "") + "</span></div>" +
'<div class="ai-body">' + Core.splitBubbles(tip.text).map(function (b) {
return '<div class="msg-bubble" style="border-color:rgba(45,224,255,.35);margin:0 0 6px">' + Core.esc(b) + "</div>";
}).join("") + "</div>" +
'<div class="ai-foot">' +
'<button class="btn success sm" id="aiFill">填入输入框</button>' +
(state.active ? '<button class="btn ghost sm" id="aiSendNow">直接发送</button>' : "") +
'<button class="btn ghost sm" id="aiClose">关闭</button>' +
"</div></div>";
$("#aiFill", wrap).onclick = function () {
$("#wxText", state.container).value = tip.text;
$("#wxText", state.container).focus();
clearAiTip();
};
var sendNow = $("#aiSendNow", wrap);
if (sendNow) sendNow.onclick = function () {
if (!state.active) return;
if (sendNow.disabled) return;
sendNow.disabled = true;
sendNow.textContent = "发送中…";
WxN.sendBubbles(state.active.wxid, tip.text).then(function () {
sendNow.disabled = false;
sendNow.textContent = "直接发送";
clearAiTip();
});
};
$("#aiClose", wrap).onclick = function () {
if (state.active) {
var bs = Core.splitBubbles(tip.text);
if (!bs.length) bs = ["(空消息)"];
bs.forEach(function (b) {
state.msgs[state.active.wxid].push({ role: "ai", text: b, time: Core.nowTime() });
});
}
clearAiTip();
WxN.renderChat();
};
}
function doAskAI() {
if (!state.ai.sessionId) {
Core.toast("尚未注册 AI 会话,请点击右上角「AI 助手设置」", "err");
openAiConfig();
return;
}
var draft = $("#wxText", state.container).value.trim();
if (!draft) { Core.toast("请先在输入框粘贴客户消息或沟通上下文", "err"); return; }
Core.loading(true, "AI 生成话术中…");
Core.api("chat/chat", { sessionId: state.ai.sessionId, content: draft })
.then(function (assistant) {
Core.loading(false);
assistant = assistant || {};
var text = assistant.result || "";
if (typeof assistant === "string") text = assistant;
if (!text) { Core.toast("AI 未返回内容", "err"); return; }
renderAiTip({
text: text,
missionStatus: assistant.mission_status || "",
missionDesc: assistant.mission_complete_desc || ""
});
})
.catch(function (e) { Core.loading(false); Core.toast("生成失败:" + e.message, "err"); });
}
/* ==================== 导出 ==================== */
WxN.clearBriefCache = clearBriefCache;
WxN.loadProfile = loadProfile;
WxN.loadContacts = loadContacts;
WxN.renderContacts = renderContacts;
WxN.loadLabels = loadLabels;
WxN.renderChatHead = WxN.renderChatHead || function () {}; // 由 wx_chat.js 覆盖
WxN.renderChat = WxN.renderChat || function () {}; // 由 wx_chat.js 覆盖
WxN.contactDisplay = contactDisplay;
WxN.connectWs = connectWs;
WxN.disconnectWs = disconnectWs;
WxN.openAiConfig = openAiConfig;
WxN.closeLabelEditor = closeLabelEditor;
global.Wx = { mount: mount, unmount: unmount };
})(window);