Add AI trading supervisor with WeChat push and daily session

Proactive monitoring for manual/hub closes and new opens prevents overtrading via in-app alerts, configurable WeChat links, and supervisor chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-23 19:25:01 +08:00
parent d3d366d0ee
commit bfbd6879d6
15 changed files with 1699 additions and 43 deletions
+30 -2
View File
@@ -4541,6 +4541,7 @@ body.hub-page-ai #page-ai {
body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel {
display: flex;
flex: 1 1 auto;
@@ -4551,12 +4552,14 @@ body.hub-page-ai #page-ai {
}
body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel {
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-history-panel {
display: none !important;
}
body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main {
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="supervisor"] .ai-chat-main {
display: flex;
flex: 1 1 auto;
min-height: 0;
@@ -5182,6 +5185,31 @@ body.hub-page-ai #page-ai {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft));
}
.ai-chat-history-badge.supervisor {
color: #c27803;
border-color: color-mix(in srgb, #c27803 45%, var(--border-soft));
}
.ai-msg-row-system {
justify-content: flex-start;
}
.ai-bubble-system {
background: color-mix(in srgb, var(--surface-2) 88%, #c27803 12%);
border: 1px solid color-mix(in srgb, var(--border-soft) 70%, #c27803 30%);
font-size: 0.92rem;
white-space: pre-wrap;
}
.ai-bubble-warn {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-soft));
}
.ai-chat-history-panel.hidden {
display: none !important;
}
.ai-chat-new-btn.hidden {
display: none !important;
}
.supervisor-settings-grid {
margin-top: 0.75rem;
}
.ai-chat-history-del {
min-width: 28px;
min-height: 28px;
+248 -36
View File
@@ -82,6 +82,28 @@
syncNavVisibility(data);
}
function syncSupervisorSettingsUI(data) {
const s = (data && data.supervisor) || {};
const enabled = document.getElementById("supervisor-enabled");
const prog = document.getElementById("supervisor-wechat-program");
const webhook = document.getElementById("supervisor-wechat-webhook");
const link = document.getElementById("supervisor-wechat-link");
const prefix = document.getElementById("supervisor-wechat-prefix");
const daily = document.getElementById("supervisor-daily-warn");
const interval = document.getElementById("supervisor-interval-warn");
const freq30 = document.getElementById("supervisor-freq-30m");
const reopen = document.getElementById("supervisor-reopen-min");
if (enabled) enabled.checked = s.enabled !== false;
if (prog) prog.checked = s.wechat_on_program_tp_sl !== false;
if (webhook) webhook.value = s.wechat_webhook || "";
if (link) link.value = s.wechat_link_base || "";
if (prefix) prefix.value = s.wechat_prefix || "【交易监管】";
if (daily) daily.value = Number(s.manual_close_daily_warn) || 2;
if (interval) interval.value = Number(s.interval_warn_minutes) || 15;
if (freq30) freq30.value = Number(s.freq_30m_count) || 2;
if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30;
}
function positionTableHeadHtml(compact) {
const pnlTh = showAccountPnlPref() ? "<th>浮盈</th>" : "";
const cls = compact ? " data-table data-table-positions" : "";
@@ -1085,6 +1107,7 @@
syncHubAiMobileViewport();
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page !== "ai") closeSupervisorStream();
if (page === "dashboard" && window.hubDashboardPage) {
window.hubDashboardPage.init();
} else if (window.hubDashboardPage && window.hubDashboardPage.destroy) {
@@ -1506,7 +1529,22 @@
}
const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab";
const AI_MOBILE_CHAT_TABS = new Set(["trading", "general"]);
const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]);
let aiSupervisorSessionCache = null;
let supervisorEventSource = null;
let localSupervisorVersion = 0;
let supervisorReconnectTimer = null;
function isSupervisorMode() {
return aiSelectedBotMode === "supervisor";
}
function normalizeAiBotMode(mode) {
const m = (mode || "").trim().toLowerCase();
if (m === "general") return "general";
if (m === "supervisor") return "supervisor";
return "trading";
}
function normalizeAiMobileTab(tab) {
const raw = (tab || "").trim().toLowerCase();
@@ -1540,6 +1578,11 @@
});
if (AI_MOBILE_CHAT_TABS.has(active)) {
updateAiBotTabs(active);
if (active === "supervisor") {
void loadAiSupervisorSession().then(() => connectSupervisorStream());
} else {
closeSupervisorStream();
}
scrollAiChatToEnd();
}
if (active === "history") {
@@ -1556,8 +1599,16 @@
const tab = btn.dataset.aiTab || "trading";
if (tab === "new") {
const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
const botMode = prev === "general" ? "general" : "trading";
void newAiChat(botMode);
const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading";
if (botMode === "supervisor") {
void switchToSupervisorMode();
} else {
void newAiChat(botMode);
}
return;
}
if (tab === "supervisor") {
void switchToSupervisorMode();
return;
}
localStorage.setItem(AI_MOBILE_TAB_KEY, tab);
@@ -3745,6 +3796,7 @@
loadMacroCalendarUI();
loadSettings().then((data) => {
syncDisplayPrefsUI(data);
syncSupervisorSettingsUI(data);
renderSettingsList(data);
});
}
@@ -3785,6 +3837,15 @@
const archiveCb = document.getElementById("pref-show-nav-archive");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const supEnabled = document.getElementById("supervisor-enabled");
const supProg = document.getElementById("supervisor-wechat-program");
const supWebhook = document.getElementById("supervisor-wechat-webhook");
const supLink = document.getElementById("supervisor-wechat-link");
const supPrefix = document.getElementById("supervisor-wechat-prefix");
const supDaily = document.getElementById("supervisor-daily-warn");
const supInterval = document.getElementById("supervisor-interval-warn");
const supFreq30 = document.getElementById("supervisor-freq-30m");
const supReopen = document.getElementById("supervisor-reopen-min");
return {
version: 1,
display: {
@@ -3796,6 +3857,17 @@
show_nav_ai: aiCb ? !!aiCb.checked : true,
show_nav_calculator: calcCb ? !!calcCb.checked : true,
},
supervisor: {
enabled: supEnabled ? !!supEnabled.checked : true,
wechat_webhook: supWebhook ? supWebhook.value.trim() : "",
wechat_link_base: supLink ? supLink.value.trim() : "",
wechat_prefix: supPrefix ? supPrefix.value.trim() : "【交易监管】",
wechat_on_program_tp_sl: supProg ? !!supProg.checked : true,
manual_close_daily_warn: supDaily ? Number(supDaily.value) || 2 : 2,
interval_warn_minutes: supInterval ? Number(supInterval.value) || 15 : 15,
freq_30m_count: supFreq30 ? Number(supFreq30.value) || 2 : 2,
reopen_after_close_minutes: supReopen ? Number(supReopen.value) || 30 : 30,
},
exchanges: rows.map((card) => {
const caps = [];
if (card.querySelector(".cap-key").checked) caps.push("key");
@@ -3830,6 +3902,7 @@
if (j.settings) {
settingsCache = j.settings;
syncDisplayPrefsUI(j.settings);
syncSupervisorSettingsUI(j.settings);
renderSettingsList(j.settings);
loadSettingsMetaLine();
} else {
@@ -4036,19 +4109,26 @@
}
function updateAiBotTabs(mode) {
const m = mode === "general" ? "general" : "trading";
const m = normalizeAiBotMode(mode);
aiSelectedBotMode = m;
document.querySelectorAll(".ai-bot-tab").forEach((btn) => {
const on = (btn.dataset.bot || "trading") === m;
const on = normalizeAiBotMode(btn.dataset.bot || "trading") === m;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
const newBtn = document.getElementById("btn-ai-chat-new");
if (newBtn) newBtn.classList.toggle("hidden", m === "supervisor");
const histPanel = document.querySelector(".ai-chat-history-panel");
if (histPanel) histPanel.classList.toggle("hidden", m === "supervisor");
const input = document.getElementById("ai-chat-input");
if (input) {
input.placeholder =
m === "general"
? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"
: "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图";
if (m === "general") {
input.placeholder = "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图";
} else if (m === "supervisor") {
input.placeholder = "回应监管提醒、说说为什么又开了一单…";
} else {
input.placeholder = "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图";
}
}
}
@@ -4090,20 +4170,33 @@
function renderAiChatRow(role, content, extraClass, attachments, rowOpts) {
const opts = rowOpts || {};
const botMode = opts.botMode === "general" ? "general" : "trading";
const botMode = normalizeAiBotMode(opts.botMode || aiSelectedBotMode);
const isUser = role === "user";
const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练";
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
const isSystem = role === "system";
let label = "主人";
if (isSystem) label = "监管";
else if (!isUser) label = botMode === "general" ? "助手" : botMode === "supervisor" ? "监管AI" : "交易教练";
const rowCls = isUser
? "ai-msg-row-user"
: isSystem
? "ai-msg-row-system"
: "ai-msg-row-coach";
const bubbleCls = isUser
? "ai-bubble-user"
: isSystem
? "ai-bubble-system"
: "ai-bubble-assistant";
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
const isError =
!isUser &&
!isSystem &&
!isThinking &&
/^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim());
const mdKey =
!isUser && !isThinking && opts.cacheKey ? String(opts.cacheKey) : "";
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "", mdKey);
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
!isUser && !isSystem && !isThinking && opts.cacheKey ? String(opts.cacheKey) : "";
const bubbleInner =
isUser || isThinking || isSystem ? esc(content || "") : renderHubMarkdown(content || "", mdKey);
const mdCls = !isUser && !isSystem && !isThinking ? " ai-result-md" : "";
const attList = Array.isArray(attachments) ? attachments : [];
const attHtml = attList.length
? `<div class="ai-msg-attachments">${attList
@@ -4129,15 +4222,20 @@
const box = document.getElementById("ai-chat-messages");
const title = document.getElementById("ai-chat-title");
if (!box) return;
const msgs = (session && session.messages) || [];
const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading";
const activeSession = isSupervisorMode() ? aiSupervisorSessionCache || session : session;
const msgs = (activeSession && activeSession.messages) || [];
const botMode = normalizeAiBotMode((activeSession && activeSession.bot_mode) || aiSelectedBotMode);
if (title) {
const modeLabel = botMode === "general" ? "普通聊天" : "交易教练";
const sessionTitle = session && session.title ? String(session.title) : "";
const modeLabel =
botMode === "general" ? "普通聊天" : botMode === "supervisor" ? "交易监管" : "交易教练";
const sessionTitle = activeSession && activeSession.title ? String(activeSession.title) : "";
if (isMobileAiLayout()) {
title.textContent = sessionTitle && sessionTitle !== "新对话"
? sessionTitle
: modeLabel;
title.textContent =
botMode === "supervisor"
? sessionTitle || "今日监管"
: sessionTitle && sessionTitle !== "新对话"
? sessionTitle
: modeLabel;
} else {
title.textContent = sessionTitle
? `${modeLabel} · ${sessionTitle}`
@@ -4150,21 +4248,24 @@
const hint =
botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。";
: botMode === "supervisor"
? "今日监管为长会话:手动/中控开平仓与新开仓会自动推送;程序止盈止损会鼓励性提醒。可直接回复继续聊。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
return;
}
const sessionId = session && session.id ? String(session.id) : "local";
const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local";
let html = msgs
.map((m, idx) =>
renderAiChatRow(
m.role === "user" ? "user" : "assistant",
.map((m, idx) => {
const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant";
return renderAiChatRow(
role,
m.content || "",
null,
m.level === "warn" ? "ai-bubble-warn" : null,
m.attachments,
{ botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx }
)
)
);
})
.join("");
if (options.pendingUser) {
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
@@ -4187,6 +4288,66 @@
});
}
async function loadAiSupervisorSession() {
const r = await apiFetch("/api/ai/supervisor/session");
const j = await r.json();
aiSupervisorSessionCache = j.session || null;
if (isSupervisorMode()) {
renderAiChatMessages(aiSupervisorSessionCache);
}
updateAiBotTabs("supervisor");
return j;
}
async function switchToSupervisorMode() {
updateAiBotTabs("supervisor");
if (isMobileAiLayout()) {
localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor");
applyAiMobileTab("supervisor");
}
try {
await loadAiSupervisorSession();
connectSupervisorStream();
scrollAiChatToEnd();
} catch (e) {
showToast(String(e), true);
}
}
function closeSupervisorStream() {
if (supervisorEventSource) {
supervisorEventSource.close();
supervisorEventSource = null;
}
if (supervisorReconnectTimer) {
clearTimeout(supervisorReconnectTimer);
supervisorReconnectTimer = null;
}
}
function connectSupervisorStream() {
closeSupervisorStream();
if (currentPage() !== "ai" || !isSupervisorMode()) return;
supervisorEventSource = new EventSource("/api/ai/supervisor/stream");
supervisorEventSource.addEventListener("supervisor", (ev) => {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.supervisor_version) || 0;
if (ver !== localSupervisorVersion) {
localSupervisorVersion = ver;
void loadAiSupervisorSession();
}
} catch (_) {}
});
supervisorEventSource.onerror = () => {
closeSupervisorStream();
if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer);
supervisorReconnectTimer = setTimeout(() => {
if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream();
}, 8000);
};
}
async function loadAiChatSession() {
const r = await apiFetch("/api/ai/chat/session");
const j = await r.json();
@@ -4313,8 +4474,15 @@
async function loadAiPage() {
applyAiMobileTab();
await loadAiChatSession();
await consumeArchiveQuoteAiPending();
const params = new URLSearchParams(window.location.search || "");
const modeParam = (params.get("mode") || "").trim().toLowerCase();
if (modeParam === "supervisor") {
await switchToSupervisorMode();
} else {
closeSupervisorStream();
await loadAiChatSession();
await consumeArchiveQuoteAiPending();
}
const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) {
const input = document.getElementById("ai-chat-input");
@@ -4325,7 +4493,8 @@
}
async function newAiChat(botMode) {
const mode = botMode === "general" ? "general" : "trading";
const mode = normalizeAiBotMode(botMode);
if (mode !== "supervisor") closeSupervisorStream();
try {
const r = await apiFetch("/api/ai/chat/new", {
method: "POST",
@@ -4342,7 +4511,13 @@
localStorage.setItem(AI_MOBILE_TAB_KEY, mode);
applyAiMobileTab(mode);
}
showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话");
showToast(
mode === "general"
? "已开始普通聊天"
: mode === "supervisor"
? "已打开今日监管"
: "已开始交易教练对话"
);
} catch (e) {
showToast(String(e), true);
}
@@ -4353,6 +4528,38 @@
if (aiChatLoading) return;
const input = document.getElementById("ai-chat-input");
const text = (input && input.value || "").trim();
if (isSupervisorMode()) {
if (!text) return;
const savedText = text;
if (input) input.value = "";
setAiChatBusy(true);
renderAiChatMessages(aiSupervisorSessionCache, {
pendingUser: text,
thinking: true,
});
try {
const r = await apiFetch("/api/ai/supervisor/chat/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
aiSupervisorSessionCache = j.session || null;
renderAiChatMessages(aiSupervisorSessionCache);
} catch (e) {
showToast(String(e), true);
if (input && savedText) input.value = savedText;
try {
await loadAiSupervisorSession();
} catch (_) {
renderAiChatMessages(aiSupervisorSessionCache);
}
} finally {
setAiChatBusy(false);
}
return;
}
const files = aiChatPendingFiles.slice();
if (!text && !files.length) return;
const pendingAttachments = files.map((f) => ({
@@ -4463,7 +4670,12 @@
if (btn._aiBotBound) return;
btn._aiBotBound = true;
btn.addEventListener("click", () => {
const mode = btn.getAttribute("data-bot") || "trading";
const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading");
if (mode === "supervisor") {
void switchToSupervisorMode();
return;
}
closeSupervisorStream();
newAiChat(mode);
});
});
+47 -1
View File
@@ -648,11 +648,12 @@
<div id="page-ai" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">AI</span> 教练</h1>
<p class="page-desc">交易教练 / 普通聊天 · 右侧可回看历史会话</p>
<p class="page-desc">交易教练 / 普通聊天 / 交易监管 · 右侧可回看历史会话</p>
</div>
<div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图">
<button type="button" class="ai-mobile-tab is-active" data-ai-tab="trading" role="tab" aria-selected="true">交易教练</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="general" role="tab" aria-selected="false">普通聊天</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="supervisor" role="tab" aria-selected="false">交易监管</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
<button type="button" class="ai-mobile-tab ai-mobile-tab-action" data-ai-tab="new" role="tab" aria-selected="false" title="新开对话">新开</button>
</div>
@@ -662,6 +663,7 @@
<div class="ai-bot-bar" role="tablist" aria-label="聊天机器人">
<button type="button" class="ai-bot-tab is-active" data-bot="trading" role="tab" aria-selected="true">交易教练</button>
<button type="button" class="ai-bot-tab" data-bot="general" role="tab" aria-selected="false">普通聊天</button>
<button type="button" class="ai-bot-tab" data-bot="supervisor" role="tab" aria-selected="false">交易监管</button>
</div>
<button type="button" id="btn-ai-chat-new" class="primary ai-chat-new-btn">新开对话</button>
</div>
@@ -902,6 +904,50 @@
</form>
<div id="macro-event-list" class="macro-event-list"></div>
</div>
<div class="settings-supervisor-panel card">
<h3 class="settings-display-title">交易监管 · 企业微信</h3>
<p class="settings-display-hint">
与四所实例策略通知独立;手动/中控开平仓与新开仓会推送至此 Webhook。链接可在下方单独修改。
</p>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="supervisor-enabled" checked />
启用交易监管推送
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="supervisor-wechat-program" checked />
程序止盈/止损也发微信(鼓励向)
</label>
<div class="settings-grid supervisor-settings-grid">
<div class="field field-wide">
<label>企业微信 Webhook</label>
<input id="supervisor-wechat-webhook" type="text" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." autocomplete="off" />
</div>
<div class="field field-wide">
<label>微信消息跳转链接(可改)</label>
<input id="supervisor-wechat-link" type="text" placeholder="https://你的域名/ai?mode=supervisor" autocomplete="off" />
</div>
<div class="field">
<label>消息前缀</label>
<input id="supervisor-wechat-prefix" type="text" value="【交易监管】" autocomplete="off" />
</div>
<div class="field">
<label>日手动平警告阈值</label>
<input id="supervisor-daily-warn" type="number" min="1" step="1" value="2" />
</div>
<div class="field">
<label>最短两笔间隔(分钟)</label>
<input id="supervisor-interval-warn" type="number" min="1" step="1" value="15" />
</div>
<div class="field">
<label>30 分钟内笔数阈值</label>
<input id="supervisor-freq-30m" type="number" min="1" step="1" value="2" />
</div>
<div class="field">
<label>平仓后再开仓(分钟)</label>
<input id="supervisor-reopen-min" type="number" min="1" step="1" value="30" />
</div>
</div>
</div>
<div class="toolbar">
<button type="button" id="btn-settings-save" class="primary">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</button>