迭代19-21收尾: 指示灯自适应探测(R-022/R-023)+ 列设置拖拽排序(R-024)+ Tab设置间隙线统一(R-025)+ 需求池归档(R-014/15/16/17/19 → 已完成)
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定)
|
||||
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定;2026-09-09 R-022 自适应探测修正)
|
||||
*
|
||||
* 机制(服务端定时 + 缓存):
|
||||
* - DSH 服务端每 5 分钟探测一次激活 QMT Bridge /health,结果缓存在内存;
|
||||
* - DSH 服务端定时探测激活 QMT Bridge /health,结果缓存在内存;
|
||||
* - 前端打开页面时读缓存(秒回,不等实时探测);
|
||||
* - 激活配置切换时立即补探一次(热切换联动);
|
||||
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存。
|
||||
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存并重排下轮节奏。
|
||||
*
|
||||
* 自适应探测节奏(R-022 / 迭代 19,2026-09-09):
|
||||
* - 健康 → 每 5 分钟探测一次(稳态,低开销,维持原设计);
|
||||
* - 异常/未知 → 每 10 秒快速重试——与持仓 10s / 行情 5s 指示灯恢复节奏对齐:
|
||||
* QMT Bridge 启动/恢复后灯 ≤20s 自动回绿(原实现要等满 5 分钟,灯长期停留在失败态,
|
||||
* 被老师反馈为「自动检查没有生效」)。
|
||||
*
|
||||
* 职责(单一):维护 QMT 健康状态缓存。
|
||||
*/
|
||||
@@ -13,36 +19,43 @@
|
||||
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 5000;
|
||||
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟定时探测
|
||||
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 健康稳态:5 分钟定时探测
|
||||
const HEALTH_RETRY_MS = 10 * 1000; // 异常/未知:10 秒快速重试(恢复感知)
|
||||
|
||||
export class QmtHealthMonitor {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
|
||||
* @param {object} [opts.logger]
|
||||
* @param {number} [opts.intervalMs] 健康稳态探测间隔(默认 5 分钟;测试可调小)
|
||||
* @param {number} [opts.retryMs] 异常/未知重试间隔(默认 10 秒;测试可调小)
|
||||
*/
|
||||
constructor({ runtime, logger } = {}) {
|
||||
constructor({ runtime, logger, intervalMs = HEALTH_INTERVAL_MS, retryMs = HEALTH_RETRY_MS } = {}) {
|
||||
this.runtime = runtime;
|
||||
this.logger = logger;
|
||||
this.intervalMs = intervalMs;
|
||||
this.retryMs = retryMs;
|
||||
this.cache = null; // { baseUrl, healthy, latencyMs, httpStatus, healthError, checkedAt }
|
||||
this.timer = null;
|
||||
this.timer = null; // setTimeout 句柄(自适应调度)
|
||||
this.mounted = true;
|
||||
this._inFlight = null; // 防止并发探测
|
||||
}
|
||||
|
||||
/** 启动:立即探测一次 + 每 5 分钟定时 */
|
||||
/** 启动:立即探测一次,之后按结果自适应调度(健康 5 分钟 / 异常 10 秒) */
|
||||
start() {
|
||||
this.mounted = true;
|
||||
this.probe(); // 启动即探测
|
||||
this.timer = setInterval(() => this.probe(), HEALTH_INTERVAL_MS);
|
||||
this.logger?.info?.('[one-divine-lot] QmtHealthMonitor 已启动(5 分钟定时探测)');
|
||||
this.probe().catch(() => {}); // 启动即探测;完成后 _scheduleNext() 排下轮
|
||||
this.logger?.info?.(
|
||||
'[one-divine-lot] QmtHealthMonitor 已启动(自适应探测:健康 ' + Math.round(this.intervalMs / 1000) +
|
||||
's / 异常 ' + Math.round(this.retryMs / 1000) + 's 重试)'
|
||||
);
|
||||
}
|
||||
|
||||
/** 停止(插件释放时) */
|
||||
stop() {
|
||||
this.mounted = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
@@ -54,20 +67,41 @@ export class QmtHealthMonitor {
|
||||
: { healthy: null, baseUrl: '', latencyMs: 0, healthError: null, checkedAt: 0, fromCache: false };
|
||||
}
|
||||
|
||||
/** 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存 */
|
||||
/** 下轮探测延迟:健康 → 稳态间隔;异常/未知(含从未探测成功)→ 快速重试 */
|
||||
_nextDelay() {
|
||||
return this.cache?.healthy === true ? this.intervalMs : this.retryMs;
|
||||
}
|
||||
|
||||
/** 自适应调度下一轮探测(每次探测完成后调用;手动探测/热切换共用同一节奏) */
|
||||
_scheduleNext() {
|
||||
if (!this.mounted) return;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.probe().catch(() => {});
|
||||
}, this._nextDelay());
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存并重排下轮节奏。
|
||||
* 任意失败(含 baseUrl 解析异常)都落 healthy:false 缓存——异常态走快速重试。
|
||||
*/
|
||||
async probe() {
|
||||
if (!this.mounted) return this.cache;
|
||||
// 防并发:同一时刻只探测一次
|
||||
if (this._inFlight) return this._inFlight;
|
||||
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||
const startedAt = Date.now();
|
||||
this._inFlight = (async () => {
|
||||
let result;
|
||||
let baseUrl;
|
||||
try {
|
||||
baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||
const startedAt = Date.now();
|
||||
const res = await fetch(baseUrl + '/health', {
|
||||
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
result = {
|
||||
return {
|
||||
baseUrl,
|
||||
healthy: res.ok,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
@@ -76,22 +110,23 @@ export class QmtHealthMonitor {
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
result = {
|
||||
baseUrl,
|
||||
return {
|
||||
baseUrl: baseUrl ?? '',
|
||||
healthy: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
latencyMs: 0,
|
||||
httpStatus: null,
|
||||
healthError: e?.message ?? String(e),
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
this.cache = result;
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await this._inFlight;
|
||||
const result = await this._inFlight;
|
||||
this.cache = result;
|
||||
return result;
|
||||
} finally {
|
||||
this._inFlight = null;
|
||||
this._scheduleNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+97
-36
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* QmtMcpManager —— QMT Bridge MCP 客户端管理(R-020 / 迭代 18)
|
||||
* QmtMcpManager —— QMT Bridge MCP 客户端管理(R-020 / 迭代 18;2026-09-09 R-023/迭代 19 加状态自适应探测)
|
||||
*
|
||||
* 职责:在神之一手插件 apply 内动态挂载 DSH 官方 @deepseek-ai/dsh-mcp-client,
|
||||
* 把 QMT Bridge /mcp 的工具以 mcp__QMT_Bridge_MCP__* 注册给模型;管理 MCP 实例的
|
||||
@@ -11,6 +11,15 @@
|
||||
* - Q5 失败不崩:failOnStartupError:false + reconnect(对齐宿主受管块同参);
|
||||
* - Q4 状态出口:mcp-status 端点 + sync-status.mcp 域(设置页 + 会话头部指示灯)。
|
||||
*
|
||||
* 状态自适应探测(R-023 / 迭代 19,2026-09-09):
|
||||
* - 原实现状态缓存只在 挂载/切换/手动探测 时刷新,无定时回路:QMT Bridge 启动/恢复后
|
||||
* MCP 灯停留在上次失败态(与 QMT 健康灯同症状,老师反馈「MCP 也没自动恢复」);
|
||||
* dsh-mcp-client 自带 reconnect 只恢复真实连接,不刷新本类状态缓存。
|
||||
* - 现按结果驱动自适应探测(与 QmtHealthMonitor 同模式):connected → intervalMs
|
||||
* (默认 5 分钟,稳态低开销);非 connected → retryMs(默认 10 秒)快速重试,与前端
|
||||
* sync-status 10s 轮询对齐,MCP 服务恢复后灯 ≤20s 自动回绿;无激活连接(url 为空)
|
||||
* 不探测不调度;dispose 后停止调度。
|
||||
*
|
||||
* 实现参照:mcp_router(ctx.plugin 动态挂载 dsh-mcp-client 实证);
|
||||
* 挂载错误必须 catch(同 serverName 重复 / 依赖缺失),不影响插件其余功能。
|
||||
*/
|
||||
@@ -21,6 +30,8 @@ import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const SERVER_NAME = 'QMT_Bridge_MCP';
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
const PROBE_INTERVAL_MS = 5 * 60 * 1000; // 已连接稳态:5 分钟探测一次
|
||||
const PROBE_RETRY_MS = 10 * 1000; // 未连接/异常:10 秒快速重试
|
||||
const MOUNT_CONFIG_BASE = {
|
||||
serverName: SERVER_NAME,
|
||||
transport: 'streamable-http',
|
||||
@@ -36,11 +47,15 @@ export class QmtMcpManager {
|
||||
* @param {object} opts.ctx Cordis 宿主 ctx
|
||||
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
|
||||
* @param {object} [opts.logger]
|
||||
* @param {number} [opts.intervalMs] 已连接稳态探测间隔(默认 5 分钟;测试可调小)
|
||||
* @param {number} [opts.retryMs] 异常/未连接重试间隔(默认 10 秒;测试可调小)
|
||||
*/
|
||||
constructor({ ctx, runtime, logger } = {}) {
|
||||
constructor({ ctx, runtime, logger, intervalMs = PROBE_INTERVAL_MS, retryMs = PROBE_RETRY_MS } = {}) {
|
||||
this.ctx = ctx;
|
||||
this.runtime = runtime;
|
||||
this.logger = logger ?? console;
|
||||
this.intervalMs = intervalMs;
|
||||
this.retryMs = retryMs;
|
||||
/** @type {Promise<object>|null} dsh-mcp-client 模块(惰性加载) */
|
||||
this._modulePromise = null;
|
||||
/** @type {object|null} 当前挂载的 fiber(ctx.plugin 返回值,含 dispose) */
|
||||
@@ -49,6 +64,10 @@ export class QmtMcpManager {
|
||||
this._currentUrl = '';
|
||||
/** 状态缓存:{ state, serverName, url, mounted, toolCount, error, latencyMs, checkedAt } */
|
||||
this.status = this._disabledStatus();
|
||||
/** setTimeout 句柄(自适应探测) */
|
||||
this.timer = null;
|
||||
this._disposed = false;
|
||||
this._probing = null; // 防并发探测
|
||||
}
|
||||
|
||||
_disabledStatus() {
|
||||
@@ -76,7 +95,7 @@ export class QmtMcpManager {
|
||||
return this._modulePromise;
|
||||
}
|
||||
|
||||
/** 启动:解析当前激活 baseUrl → 挂载 + 探测(非阻塞,内部 catch) */
|
||||
/** 启动:解析当前激活 baseUrl → 挂载 + 探测(非阻塞,内部 catch);探测完成即排自适应轮询 */
|
||||
async start() {
|
||||
try {
|
||||
const url = this._currentMcpUrl();
|
||||
@@ -153,11 +172,12 @@ export class QmtMcpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 显式卸载(置 disabled 状态) */
|
||||
/** 显式卸载(置 disabled 状态,取消自适应调度) */
|
||||
async unmount() {
|
||||
this._unmountFiber();
|
||||
this._currentUrl = '';
|
||||
this.status = this._disabledStatus();
|
||||
this._cancelTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,47 +208,86 @@ export class QmtMcpManager {
|
||||
/**
|
||||
* 独立探测:SDK Client 连 {url} → initialize + tools/list → 工具数/延迟。
|
||||
* 与 dsh-mcp-client 自管连接互不干扰(只读检查,用完即关)。
|
||||
* 探测结果写缓存,并按其重排自适应轮询节奏(connected → 稳态 / 其他 → 快速重试)。
|
||||
* @param {string} [url] 缺省用当前激活派生 url
|
||||
*/
|
||||
async probe(url) {
|
||||
const target = String(url ?? this._currentMcpUrl() ?? '').trim().replace(/\/+$/, '');
|
||||
if (!target) {
|
||||
this.status = this._disabledStatus();
|
||||
this._cancelTimer(); // 无激活连接:不探测不调度
|
||||
return this.status;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let client = null;
|
||||
try {
|
||||
client = new Client({ name: 'one-divine-lot-mcp-probe', version: '0.1.0' }, { capabilities: {} });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(target), {
|
||||
requestInit: { headers: {} },
|
||||
});
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
|
||||
]);
|
||||
const list = await Promise.race([
|
||||
client.listTools(),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
|
||||
]);
|
||||
const tools = Array.isArray(list?.tools) ? list.tools : [];
|
||||
this.status = {
|
||||
state: 'connected', serverName: SERVER_NAME, url: target,
|
||||
mounted: !!this._fiber && this._currentUrl === target,
|
||||
toolCount: tools.length, error: null,
|
||||
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
|
||||
};
|
||||
try { await client.close(); } catch { /* ignore */ }
|
||||
} catch (err) {
|
||||
this.status = {
|
||||
state: 'error', serverName: SERVER_NAME, url: target,
|
||||
mounted: !!this._fiber, toolCount: 0,
|
||||
error: (err?.message ?? String(err)).slice(0, 300),
|
||||
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
|
||||
};
|
||||
// 防并发:同一时刻只探测一次
|
||||
if (this._probing) return this._probing;
|
||||
this._probing = (async () => {
|
||||
const startedAt = Date.now();
|
||||
let client = null;
|
||||
let next;
|
||||
try {
|
||||
client = new Client({ name: 'one-divine-lot-mcp-probe', version: '0.1.0' }, { capabilities: {} });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(target), {
|
||||
requestInit: { headers: {} },
|
||||
});
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
|
||||
]);
|
||||
const list = await Promise.race([
|
||||
client.listTools(),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('探测超时 (' + PROBE_TIMEOUT_MS + 'ms)')), PROBE_TIMEOUT_MS)),
|
||||
]);
|
||||
const tools = Array.isArray(list?.tools) ? list.tools : [];
|
||||
next = {
|
||||
state: 'connected', serverName: SERVER_NAME, url: target,
|
||||
mounted: !!this._fiber && this._currentUrl === target,
|
||||
toolCount: tools.length, error: null,
|
||||
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
next = {
|
||||
state: 'error', serverName: SERVER_NAME, url: target,
|
||||
mounted: !!this._fiber, toolCount: 0,
|
||||
error: (err?.message ?? String(err)).slice(0, 300),
|
||||
latencyMs: Date.now() - startedAt, checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
try { await client?.close(); } catch { /* ignore */ }
|
||||
return next;
|
||||
})();
|
||||
try {
|
||||
const result = await this._probing;
|
||||
this.status = result;
|
||||
return result;
|
||||
} finally {
|
||||
this._probing = null;
|
||||
this._scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
/** 下轮探测延迟:已连接 → 稳态间隔;其他(异常/未连接等)→ 快速重试 */
|
||||
_nextDelay() {
|
||||
return this.status?.state === 'connected' ? this.intervalMs : this.retryMs;
|
||||
}
|
||||
|
||||
/** 自适应调度下一轮探测(每次探测完成后调用;手动探测/热切换共用同一节奏) */
|
||||
_scheduleNext() {
|
||||
if (this._disposed) return;
|
||||
this._cancelTimer();
|
||||
let hasTarget = false;
|
||||
try { hasTarget = !!this._currentMcpUrl(); } catch { return; }
|
||||
if (!hasTarget) return; // 无激活连接:不探测不调度
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.probe().catch(() => {});
|
||||
}, this._nextDelay());
|
||||
}
|
||||
|
||||
_cancelTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/** 读取缓存状态(前端秒回) */
|
||||
@@ -236,8 +295,10 @@ export class QmtMcpManager {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/** 释放(插件 dispose 时调用) */
|
||||
/** 释放(插件 dispose 时调用):停止调度 + 卸载 */
|
||||
async dispose() {
|
||||
this._disposed = true;
|
||||
this._cancelTimer();
|
||||
await this.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user