迭代19-21收尾: 指示灯自适应探测(R-022/R-023)+ 列设置拖拽排序(R-024)+ Tab设置间隙线统一(R-025)+ 需求池归档(R-014/15/16/17/19 → 已完成)

This commit is contained in:
2026-09-10 14:04:51 +08:00
parent 8782aa9c50
commit 3dfa39b8bf
29 changed files with 1216 additions and 72 deletions
+57 -22
View File
@@ -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 / 迭代 192026-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
View File
@@ -1,5 +1,5 @@
/**
* QmtMcpManager —— QMT Bridge MCP 客户端管理(R-020 / 迭代 18)
* QmtMcpManager —— QMT Bridge MCP 客户端管理(R-020 / 迭代 182026-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 / 迭代 192026-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_routerctx.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} 当前挂载的 fiberctx.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();
}
}