迭代19-21收尾: 指示灯自适应探测(R-022/R-023)+ 列设置拖拽排序(R-024)+ Tab设置间隙线统一(R-025)+ 需求池归档(R-014/15/16/17/19 → 已完成)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 计划:策略会话——会话以策略当前数据为依据做讨论(阶段航点)
|
||||
|
||||
> 来源:R-019(**已定稿**,2026-09-08)| 状态:执行中(设计阶段先行)| 所属迭代:17-策略会话
|
||||
> 来源:R-021(合并原 R-019+R-021,2026-09-08 已定稿)| 状态:执行中(设计阶段先行)| 所属迭代:17-策略会话
|
||||
> 粒度:阶段航点(覆盖设计 → 宿主调研 → 实现 → 验收全过程,随进展拆分小任务)
|
||||
|
||||
## 目标
|
||||
@@ -20,4 +20,4 @@
|
||||
|
||||
## 边界
|
||||
|
||||
见 R-019 定稿边界:不用宿主工作区;不开放账本写;不做按需取数工具;复盘产物不落盘;本期不做通用视图(全部持仓/交易记录)作依据。
|
||||
见 R-021 定稿边界(合并原 R-019+R-021):不用宿主工作区;不开放账本写;不做按需取数工具;复盘产物不落盘;本期不做通用视图(全部持仓/交易记录)作依据。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 迭代 17 UI 交互设计:策略会话(讨论依据控件)
|
||||
|
||||
> 依据:R-019(已定稿)+ 产品逻辑设计(同迭代)| 日期:2026-09-08 | 状态:**设计稿(D 问题待拍板)**
|
||||
> 依据:R-021(合并原 R-019+R-021,已定稿)+ 产品逻辑设计(同迭代)| 日期:2026-09-08 | 状态:**设计稿(D 问题待拍板)**
|
||||
> 视觉基线:UI约束-004(--dsw-* token)/ UI约束-001(头部 chip 形态)/ 现有 QmtConnectionChip 交互模式;复用 Toast、外点关闭、RPC(useRpc) 机制。
|
||||
|
||||
## 1. 交互总览(一句话)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 迭代 17 产品逻辑设计:策略会话
|
||||
|
||||
> 依据:R-019(已定稿)| 日期:2026-09-08 | 状态:**设计稿(D 问题待老师拍板后定稿)**
|
||||
> 依据:R-021(合并原 R-019+R-021,已定稿)| 日期:2026-09-08 | 状态:**设计稿(D 问题待老师拍板后定稿)**
|
||||
|
||||
## 0. 一句话逻辑
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
## 5. 非目标(本期明确不做)
|
||||
|
||||
- 宿主工作区/目录注册(R-019 R3);
|
||||
- 宿主工作区/目录注册(原 R-019 R3,已并入 R-021);
|
||||
- 账本写/交易执行、任何操作按钮;
|
||||
- 按需取数工具(AI 自行调用业务接口);
|
||||
- 复盘产物落盘 / 复盘管理(含 DB)——未来 T-002 方向;
|
||||
@@ -81,6 +81,46 @@
|
||||
|
||||
注入的可行通道候选:① 宿主 injected-context/上下文节点(可折叠条目、非用户消息)→ 首选;② 会话 send(文本消息形态进会话)→ 兜底(呈现为一条前置依据消息)。**渲染与动作方案以调研结果为准**(本期设计保留两种形态的表达,UI 交互设计按"数据依据条目"统一描述)。
|
||||
|
||||
## 0.5 产品逻辑总图(2026-09-08 老师确认:画像 / 模型 / 主线 / 分期草案)
|
||||
|
||||
### 目标交易者画像(Q-画像,2026-09-08 确认)
|
||||
- **混合交易模式的多策略分仓管理**:同一账户下并存多种交易风格的策略分组(当前:手动做T + 程序化网格超市;未来:情绪流交易风格的管理逻辑);
|
||||
- 即"策略 = 分仓 + 交易风格/规则的容器";系统须能承载 手动主观 + 自动化规则 + 未来情绪流 三类玩法(彼此数据/规则/复盘口径不同)。
|
||||
|
||||
### 交易员工作模型(知识库归纳,作为产品逻辑基准)
|
||||
- **日循环**:盘前计划 → 盘中执行/监控 → 盘后记账核对 → 复盘总结 → 明日计划;
|
||||
- **策略循环**:规则制定 → 按规则执行 → 规则校验(复盘)→ 规则修订;
|
||||
- **复盘闭环**:重建事实 → 归因 → 对照规则 → 提炼改进;**复盘原料 = 决策时的依据记录**(知识库共识:没有决策留痕的复盘只是猜)。
|
||||
|
||||
### AI 分工定位(人机合一,只读)
|
||||
- **盘中 = AI 参谋台**(状态聚合 / 规则触发提醒 / 轻问答 / 决策留痕 / 人扣扳机执行),快、准、不打断;
|
||||
- **盘后 = AI 复盘主持人**(自动重建事实线 → 引导人过结论 → 结论留痕),人不被 AI 下"对错"裁决;
|
||||
- 决策依据记录(盘中一键留痕:理由 + 当时快照)是产品逻辑主线之一(2026-09-08 老师确认)——复盘有据的前提。
|
||||
|
||||
### 能力地图(现状 → 缺口)
|
||||
- 已具备"事实记录层":对账单快照 / 策略账本(唯一权威)/ 交易记录+归属 / 行情快照 / 策略 configSchema(可承载规则参数);
|
||||
- 缺口(按承接排序):①复盘事实线入口+可讨论会话(R-021 合并需求,本迭代)②决策依据留痕(盘中)③规则触发提醒 ④盘后自动日结/统计 ⑤复盘管理库(决策日志+复盘记录结构化存储检索)。
|
||||
|
||||
### 分期草案(2026-09-08 老师认可分步实现;**各 Phase 具体内容待讨论细化**)
|
||||
| 阶段 | 主题 | 交付设想(草案) |
|
||||
|---|---|---|
|
||||
| Phase 0(迭代 17) | 复盘会话闭环 | R-021 策略会话复盘(原 R-019+R-021 合并;策略数据依据 + 粒度复盘上下文,事实线 → 会话讨论 → 结论在会话) |
|
||||
| Phase 1 | 盘中辅助(雏形) | 决策依据一键留痕(理由+快照落库)+ 规则触发/偏离提醒雏形(configSchema × 行情)+ 会话内盘中快查 |
|
||||
| Phase 2 | 盘后日结与统计 | 自动日结(当日各策略事实线+盈亏+异常)→ 一键入会话复盘;周期统计(轮次收益/胜率/策略健康) |
|
||||
| Phase 3 | 复盘管理库 | 决策日志+复盘结论结构化沉淀/检索/关联(DB),对接目标-006;情绪流风格管理逻辑另立项 |
|
||||
|
||||
## 0.6 Phase 0 范围确认(2026-09-08 老师确认:就这样)
|
||||
|
||||
- **Phase 0 = 复盘会话闭环,范围 = R-021 单一合并需求(原 R-019 + R-021,2026-09-08 老师指令合并、逻辑整合入 R-021)**;
|
||||
- **复盘单元建模(A 拍板)**:引入"轮次 round"概念(holding 之下的交易周期聚合层):
|
||||
- 网格超市:一次买入触发 + 对应卖出配对 = 一轮(每格触发记一轮);
|
||||
- 手动做T:一次开仓 → 清仓 = 一轮;
|
||||
- 支持人工合并/拆分修正(先人工可调);
|
||||
- round 承接"复盘对象"的最小粒度,委托/段为 round 内明细;
|
||||
- **行情口径(C 拍板)**:本期事实线 = 交易记录 + 持仓史 + 现价对照(无历史 K 线/分时);历史行情背景 Phase 3 复盘库时再评估;
|
||||
- **决策日志预留(拍板)**:Phase 0 设计为"决策依据记录"留数据模型位置(时刻/理由/类别/当时快照/关联 round 或 holding),Phase 1 实现,不返工;
|
||||
- **风格差异**:复盘口径按策略类型可扩展(手动/网格,未来情绪流预留策略类型维度)。
|
||||
|
||||
## 7. D 问题(AI 建议项,待老师拍板)
|
||||
|
||||
| # | 问题 | AI 建议 |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 目标描述
|
||||
|
||||
- 老师原始诉求「新需求,让 AI 会话可以以策略为 Workspace」经四轮讨论定稿(R-019,2026-09-08):
|
||||
- 老师原始诉求「新需求,让 AI 会话可以以策略为 Workspace」经四轮讨论定稿(R-019,2026-09-08;同日老师指令 R-019 并入 R-021 合并为单一需求):
|
||||
- **改造现有 DSH 对话窗口**,不引入宿主工作区/目录;
|
||||
- 会话的数据 = ①开始前/过程中注入的策略当前数据摘要 + ②会话过程本身产生的对话;复盘管理(可能带 DB 沉淀)为神之一手未来模块,本期不做;
|
||||
- 注入口径:当前持仓明细 + 该策略全历史关联交易聚合摘要(含已清仓);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 技术实现方案:19-指示灯状态自适应探测
|
||||
|
||||
> 迭代编号:19 | 依据:R-022 + R-023 | 涉及约束:产品约束-012(指示灯体系)、技术约束-016、技术约束-020/021(本迭代新增)
|
||||
|
||||
## 方案总览
|
||||
|
||||
两只连接类状态缓存统一改「**结果驱动 setTimeout 自适应链**」:每次探测完成(成功/失败/异常)后按结果排下一轮。
|
||||
|
||||
| 维度 | QmtHealthMonitor(R-022) | QmtMcpManager(R-023) |
|
||||
|---|---|---|
|
||||
| 健康/已连接 | intervalMs 默认 5 分钟(稳态,原设计) | intervalMs 默认 5 分钟 |
|
||||
| 异常/未知/未连接 | retryMs 默认 10 秒快速重试 | retryMs 默认 10 秒快速重试 |
|
||||
| 探测目标 | 激活 baseUrl + /health(GET) | 激活 baseUrl + /mcp(SDK Client 握手,只读) |
|
||||
| 停止调度 | stop()(mounted 守卫) | 无激活连接 / dispose() / unmount() |
|
||||
| 并发防护 | _inFlight 去重 | _probing 去重 |
|
||||
| 前端/API | 零改动(读缓存秒回) | 零改动(getStatus 读缓存秒回) |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### R-022 QmtHealthMonitor(src/data-source/QmtHealthMonitor.js)
|
||||
|
||||
1. 常量 HEALTH_RETRY_MS=10s;构造注入 { intervalMs, retryMs }(测试用短间隔,对齐 QuoteSync 先例);
|
||||
2. 固定 setInterval(5min) → _nextDelay()/_scheduleNext()(setTimeout 链,探测完成重排);
|
||||
3. resolveActiveBaseUrl 移入 try:解析失败也落 healthy:false 缓存走快速重试
|
||||
(原实现解析在 try 外,抛错场景灯会灰且不自动重试——顺带修复的隐性缺陷);
|
||||
4. start() 立即探测不变;stop() 用 clearTimeout + mounted 守卫。
|
||||
|
||||
### R-023 QmtMcpManager(src/mcp/QmtMcpManager.js)
|
||||
|
||||
1. 常量 PROBE_RETRY_MS=10s;构造注入 { intervalMs, retryMs };
|
||||
2. probe() 结果写缓存后 _scheduleNext();target 为空早退置 disabled 并取消定时器;
|
||||
3. unmount() 取消定时器;dispose() 置 _disposed + 取消 + 卸载;_probing 防并发;
|
||||
4. 探测本体(SDK Client connect/initialize/tools/list)与挂载/重连逻辑零改动。
|
||||
|
||||
### 回归
|
||||
|
||||
- scripts/test-health-monitor.mjs(10 项:启动即探测/异常快速重试自动恢复/健康稳态不空转/stop 停止调度…)
|
||||
- scripts/test-mcp-status.mjs(12 项:无激活连接不调度/异常快速重试自动恢复/已连接稳态不空转/dispose 停止调度/节奏选择;
|
||||
SDK 握手不可纯内存 mock,守护调度机制:stub probe 翻转状态 + 触达真实 _scheduleNext)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- src/data-source/QmtHealthMonitor.js、src/mcp/QmtMcpManager.js(核心改动)
|
||||
- scripts/test-health-monitor.mjs、scripts/test-mcp-status.mjs(新增回归)
|
||||
- lib/*(build 再产物,部署用)
|
||||
@@ -0,0 +1,45 @@
|
||||
# 迭代复盘:19-指示灯状态自适应探测
|
||||
|
||||
## 结论
|
||||
|
||||
达成(R-022 + R-023 全范围):会话头部两只「连接类」指示灯(QMT连接 / MCP)统一改为
|
||||
**结果驱动自适应探测**——健康/已连接维持 5 分钟稳态(原设计不变),异常/未知每 10 秒快速重试;
|
||||
QMT Bridge 与其 MCP 服务启动/恢复后两灯 ≤20s 自动回绿,不再等满 5 分钟 / 不再永不恢复。
|
||||
前端指示灯与 sync-status / qmt-health / mcp-status 端点零改动(读缓存秒回语义不变)。
|
||||
|
||||
## 事实记录
|
||||
|
||||
### R-022 QMT 健康灯(src/data-source/QmtHealthMonitor.js)
|
||||
|
||||
- 根因(已证实):健康缓存固定 5 分钟探测一次激活 /health;其余三灯有高频自愈回路
|
||||
(持仓 10s / 行情 5s / MCP 客户端重连)。Bridge 在两次探测间启动/恢复时缓存停留在上次失败结果。
|
||||
- 变更:setInterval(5min) → setTimeout 自适应链(_nextDelay/_scheduleNext,结果驱动);
|
||||
HEALTH_RETRY_MS=10s;构造注入 { intervalMs, retryMs };resolveActiveBaseUrl 移入 try
|
||||
(解析失败也落 healthy:false 走快速重试,原实现该场景灯灰且不自动重试);_inFlight 防并发保留。
|
||||
|
||||
### R-023 MCP 状态灯(src/mcp/QmtMcpManager.js)
|
||||
|
||||
- 根因(已证实):状态缓存只在 挂载/切换/手动探测 时刷新,无定时回路;dsh-mcp-client 自带 reconnect
|
||||
只恢复真实连接(fiber 层),不刷新插件状态缓存。
|
||||
- 变更:probe() 结果写缓存后 _scheduleNext();PROBE_RETRY_MS=10s;构造注入 { intervalMs, retryMs };
|
||||
无激活连接早退置 disabled 并取消定时器;unmount()/dispose() 停止调度;_probing 防并发;
|
||||
探测本体与挂载/重连逻辑零改动。
|
||||
|
||||
### 验证
|
||||
|
||||
- scripts/test-health-monitor.mjs 10/10 绿;scripts/test-mcp-status.mjs 12/12 绿(调度机制守护);
|
||||
- pnpm typecheck 0 错;pnpm build 通过(lib 两文件均含自适应逻辑);
|
||||
- 存量回归 test-quote-sync / test-position-sync 均 0 退出。
|
||||
|
||||
## 待老师确认事项
|
||||
|
||||
1. R-022 / R-023 定稿确认(边界:健康/已连接稳态 5 分钟不变 / 异常 10s 重试 / 不新增配置 / API 前端零改动);
|
||||
2. 真实环境人工验收(验收标准.md 第 8 条):停/启 QMT Bridge → QMT 灯与 MCP 灯 ≤20s 自动回绿、
|
||||
点击仍即时探测、插件重载后缓存秒回。
|
||||
|
||||
## 经验沉淀(候选)
|
||||
|
||||
- 定时轮询类功能的恢复感知要与故障探测解耦:固定低频定时器做稳态,异常态必须用快速重试把「恢复」
|
||||
也纳入自愈回路——灯类状态"看起来没生效"的根因通常是恢复路径的响应时延,而不是探测本身失效。
|
||||
- 「外层有自管重连」不等于「状态缓存会自愈」:重连只恢复真实连接,展示层缓存若无自己的刷新回路,
|
||||
必须单独加状态自适应探测(R-023 教训)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 迭代目标:19-指示灯状态自适应探测(QMT 健康灯 + MCP 灯)
|
||||
|
||||
## 目标
|
||||
|
||||
会话头部两只「连接类」指示灯(QMT连接 / MCP)改为**结果驱动自适应探测**:
|
||||
健康/已连接时维持 5 分钟稳态探测(低开销),异常/未知时每 10 秒快速重试,
|
||||
使 QMT Bridge 与其 MCP 服务启动/恢复后指示灯 ≤20s 自动回绿,不再等满 5 分钟。
|
||||
|
||||
## 目标描述
|
||||
|
||||
- **背景**:两只灯的数据分别来自 QmtHealthMonitor / QmtMcpManager 的内存状态缓存,刷新机制均为
|
||||
固定/缺失低频定时回路——QMT 健康缓存固定每 5 分钟探测一次(R-022),MCP 状态缓存只在
|
||||
挂载/切换/手动探测时刷新、无定时回路(R-023)。持仓(10s)/ 行情(5s)灯靠各自定时同步秒级自愈;
|
||||
唯独两只连接灯恢复感知滞后,老师实测反馈「QMT连接灯一直没亮」「MCP 灯也不自动恢复」(2026-09-09)。
|
||||
- **范围**:只改 QmtHealthMonitor(R-022)与 QmtMcpManager(R-023)的探测调度;前端指示灯组件、
|
||||
sync-status / qmt-health / mcp-status 端点零改动;健康稳态间隔 5 分钟不变;不新增配置项。
|
||||
- **验收线**:两灯异常/未知态 ≤10s 重试;健康/已连接态维持 5 分钟不空转;服务恢复后缓存回正常态
|
||||
沿快速重试被感知;无激活连接与 dispose 后停止调度。对应需求 R-022 + R-023。
|
||||
|
||||
## 目标讨论过程
|
||||
|
||||
1. 老师报告症状(2026-09-09):Bridge 启动后其他三灯自动点亮,QMT 灯不亮 → 定位为健康缓存固定 5 分钟探测。
|
||||
2. AI 提议自适应节奏(健康 5min / 异常 10s),老师确认方向,迭代 19 实施(R-022)。
|
||||
3. 老师复查发现 MCP 灯同样不自动恢复(R-023)→ 确认 QmtMcpManager 状态缓存无定时回路,
|
||||
dsh-mcp-client 重连只恢复真实连接、不刷新本类缓存 → 同模式加自适应探测,并入本次迭代。
|
||||
4. 决策点见 R-022 / R-023 决策记录。
|
||||
|
||||
## 对老师(项目主理人)的配合需求
|
||||
|
||||
- 确认 R-022 / R-023 定稿与迭代 19 验收(核实标准见验收标准.md);
|
||||
- 真实环境人工验收:停/启 QMT Bridge → QMT 灯 ≤20s 回绿、MCP 灯 ≤20s 回绿;点击灯/「检测 MCP」仍即时探测;
|
||||
插件重载后灯按缓存秒回并正常。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 验收标准:19-指示灯状态自适应探测
|
||||
|
||||
## 验收标准线
|
||||
|
||||
| # | 标准 | 判定 |
|
||||
|---|---|---|
|
||||
| 1 | QMT 健康:异常/未知态按 retryMs(默认 10s)快速重试 | test-health-monitor [2]:≥2 次快速重试且缓存 healthy=false |
|
||||
| 2 | QMT 健康:Bridge 恢复后 ≤ 一个重试周期缓存回 healthy=true(灯回绿),无需等 5 分钟 | 同脚本 [2] |
|
||||
| 3 | QMT 健康:健康态维持稳态间隔(默认 5 分钟)不空转 | 同脚本 [3] |
|
||||
| 4 | QMT 健康:stop() 后停止自动调度 | 同脚本 [4] |
|
||||
| 5 | MCP:无激活连接置 disabled 且不调度(真实 probe 早退不触 SDK) | test-mcp-status [1] |
|
||||
| 6 | MCP:异常态按 retryMs 快速重试;MCP 服务恢复后自动探测到 connected(灯回绿) | 同脚本 [2] |
|
||||
| 7 | MCP:已连接态按 intervalMs 稳态不空转;dispose() 后停止调度;_nextDelay 节奏正确 | 同脚本 [3][4][5] |
|
||||
| 8 | 启动即探测 + 读缓存秒回语义不变(QMT 健康 / MCP 状态) | 两脚本 [1];sync-status/qmt-health/mcp-status 端点零改动 |
|
||||
| 9 | 构建产物(lib/)含两处自适应逻辑;typecheck 0 错;存量不回归 | pnpm typecheck + pnpm build;test-quote-sync / test-position-sync 通过 |
|
||||
|
||||
## 验收方法
|
||||
|
||||
- 自动化:node scripts/test-health-monitor.mjs(10/10)+ node scripts/test-mcp-status.mjs(12/12)全绿;
|
||||
pnpm typecheck;pnpm build;存量回归脚本 0 退出。
|
||||
- 人工(老师真实环境):重启插件后四灯状态秒回;停 QMT Bridge → QMT 灯 ≤10s 转红、MCP 灯 ≤10s 转红、
|
||||
持仓/行情灯按 30s/15s 阈值转黄;**重新启动 QMT Bridge → QMT 灯与 MCP 灯 ≤20s 自动回绿**(本次修复核心);
|
||||
点击 QMT 灯 /「检测 MCP」仍即时探测并刷新悬停详情。
|
||||
|
||||
## 验收目标
|
||||
|
||||
修复确认:QMT Bridge 及其 MCP 服务启动/恢复后,会话头部 QMT连接灯与 MCP 灯自动回绿(≤20s),
|
||||
与其余两盏数据灯恢复节奏一致;健康/已连接稳态无额外探测开销;前端与 API 行为不变。
|
||||
验收通过后 R-022 / R-023 定稿并归档(老师确认)。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 技术实现方案:20-策略列设置拖拽排序
|
||||
|
||||
## 总体思路
|
||||
|
||||
在 ColumnSettingsPopover.jsx 内复用 Tab 设置(SettingsSection.jsx TabSettings)已验证的
|
||||
**原生 HTML5 Drag & Drop** 模式:行 draggable + onDragStart/onDragOver/onDrop + 落点高亮 + drop 重排,
|
||||
零第三方依赖。持久化沿用现有 strategy-columns/update 整表覆盖 RPC,把「保存」语义从手动按钮改为
|
||||
每次变更(drop / 勾选 / ↑↓)立即提交。
|
||||
|
||||
## 改动点(单文件:src/client/views/ColumnSettingsPopover.jsx)
|
||||
|
||||
### 1. 状态
|
||||
|
||||
- 已有 `dragKey`(未被使用,正好用于 DnD):当前被拖行的 column.key;
|
||||
- 新增 `overKey`:当前悬停落点的 column.key(用于高亮);
|
||||
- `saving` 保持:正在写库时禁止再次拖动/勾选。
|
||||
|
||||
### 2. 持久化函数(替换原 save)
|
||||
|
||||
- 新增 `persist(nextList, msg)`:提交整表 `{key, visible}` → strategy-columns/update;
|
||||
成功 Toast 提示 + onSaved() 刷新;失败 Toast 错误 + 本地回滚(还原为服务端最新列配置)。
|
||||
- 勾选显隐:切完立即 persist(不再等「保存」按钮);
|
||||
- ↑↓ 箭头:移动完立即 persist;
|
||||
- drop 重排:重排完立即 persist(对齐 Tab 设置)。
|
||||
|
||||
### 3. 拖拽事件(照搬 TabSettings 模式)
|
||||
|
||||
```jsx
|
||||
<div key={c.key} draggable={!saving}
|
||||
onDragStart={(e) => { setDragKey(c.key); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverKey(c.key); }}
|
||||
onDragLeave={() => { if (overKey === c.key) setOverKey(null); }}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(c.key); }}
|
||||
style={{ cursor: saving ? 'default' : 'grab',
|
||||
background: overKey === c.key ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent' }}>
|
||||
<span>≡</span>
|
||||
<button>↑</button><button>↓</button>
|
||||
<input type="checkbox" ... />
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. handleDrop(照搬 TabSettings 逻辑,key 定位)
|
||||
|
||||
```js
|
||||
const handleDrop = (targetKey) => {
|
||||
if (!dragKey || dragKey === targetKey) { setDragKey(null); setOverKey(null); return; }
|
||||
const from = list.findIndex((c) => c.key === dragKey);
|
||||
const to = list.findIndex((c) => c.key === targetKey);
|
||||
if (from < 0 || to < 0) { setDragKey(null); setOverKey(null); return; }
|
||||
const next = list.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
setDragKey(null); setOverKey(null);
|
||||
persist(next, `「${moved.label}」已移动`, next);
|
||||
};
|
||||
```
|
||||
|
||||
### 5. UI 文案
|
||||
|
||||
- 标题下提示改为:「勾选显示列,按住 ≡ 拖动调整顺序;代码/名称/操作固定」;
|
||||
- 底部按钮区:去掉「保存」,保留「关闭」;
|
||||
- 每行最前加 ≡ 手柄(配色沿用 Tab 设置 `#999`/label-tertiary)。
|
||||
|
||||
## 边界与不做
|
||||
|
||||
- 服务端、API、表格渲染零改动(strategy-columns/update 已是整表覆盖语义);
|
||||
- 固定列不参与排序,不进列表(现状维持);
|
||||
- 不做方案 C(表格表头直接拖列头),不引入第三方 DnD 库;
|
||||
- `saving` 期间禁用拖拽与勾选,防并发写。
|
||||
|
||||
## 回归
|
||||
|
||||
- scripts/test-r013-columns.mjs(列配置归一化/读写的服务端逻辑,未改动应全绿);
|
||||
- npm run typecheck + build(tsdown 产物,弹层 JSX 变更需重编 web bundle);
|
||||
- 页面人工验收:拖拽重排 / 落点高亮 / drop 即存 / ↑↓ 微调 / 刷新保持。
|
||||
## 实现纪要(2026-09-10,老师验收反馈后修订)
|
||||
|
||||
> 老师反馈:初版按原方案做的是「整行高亮」(overKey → 目标行淡蓝底),拖动时无法判断会插入目标列的**前面还是后面**,
|
||||
> 观感不符合习惯。定稿修订为 **间隙高亮线**:
|
||||
|
||||
- 落点判定:拖动悬停时读鼠标在目标行内的 **Y 坐标**(getBoundingClientRect),上半 → 插入该行**之前**(线画行上缘),
|
||||
下半 → 插入该行**之后**(线画行下缘);两行之间显示 3px 主色圆角高亮线(absolute 定位在行自身 padding 内,left/right 6,
|
||||
无负偏移无裁剪风险),指示精确插入位置。
|
||||
- drop 判定:drop 事件内直接按坐标重算 before(不依赖 state 闭包,避免滞后);无操作原地(insertAt 等于原槽位)
|
||||
跳过写库;移除后目标下标左移(to > from 时 to-1)。
|
||||
- 状态:overKey(整行)→ overPos { idx, half }(间隙线锚点行 + 上下缘);保留 onDragEnd 清理防拖出弹层残留。
|
||||
- 边界维持:固定列不参与、服务端零改动、↑↓ 兜底保留、立即持久化不变。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 迭代复盘:20-策略列设置拖拽排序
|
||||
|
||||
## 结论
|
||||
|
||||
达成(R-024):策略 tab「列设置」弹层的列排序从 ↑↓ 箭头点按升级为**鼠标选中后拖动排序**,
|
||||
复用 R-011 Tab 设置已验证的原生 HTML5 DnD 模式;老师 2026-09-10 页面确认交互直观,验收通过。
|
||||
|
||||
## 事实记录
|
||||
|
||||
### src/client/views/ColumnSettingsPopover.jsx(单文件改动,服务端零改动)
|
||||
|
||||
- 排序交互升级:每行新增 ≡ 拖拽手柄(整行 draggable),拖到目标位置松手即重排;↑↓ 箭头保留作兜底微调。
|
||||
- **落点立即持久化**:勾选显隐 / 拖拽 / ↑↓ 任一操作立即调 strategy-columns/update 整表提交;弹层去掉「保存」
|
||||
按钮只留「关闭」,Toast 反馈;saving 期间禁用拖拽与勾选防并发写;失败回滚到服务端最新配置。
|
||||
- **落点指示修订(老师验收反馈)**:初版为整行高亮(overKey → 目标行淡蓝底),老师反馈无法判断插入目标列的
|
||||
前/后;改为**行间间隙高亮线**——按鼠标在目标行内的 Y 坐标取上半/下半,决定线画在该行上缘(插其前)/下缘(插其后),
|
||||
明确指示精确插入位置;drop 事件内按坐标重算(不依赖 state 闭包),拖回原位置不重复写库,onDragEnd 清理残留。
|
||||
- 修复:间隙线渲染曾误将 style 对象直接作 React child(`{gapLineStyle(i)}`),改为条件渲染 `<div style={gapLineStyle(i)}/>`。
|
||||
|
||||
### 验证
|
||||
|
||||
- pnpm typecheck 0 错;pnpm build 通过(client bundle 185KB 含间隙线逻辑,grep 确认编译产物);
|
||||
- test-r013-columns.mjs 回归:11 过 / 5 败 —— **与本次改动无关**(git stash 基线验证同样 11/5);
|
||||
失败根因:脚本仍按 R-013 时期期望「6 个基础列」,而 COLUMN_META 已有 10 列(R-015 新增涨停/跌停/今开/最高 4 个默认隐藏列),
|
||||
测试期望未随迭代 13 更新的存量债务,不在本迭代范围,列为遗留事项。
|
||||
|
||||
### 文档
|
||||
|
||||
- docs/05-需求池/R-024.md:定稿 + 变更记录(整行高亮 → 间隙高亮线);
|
||||
- docs/03-设计约束/UI交互约束.md:新增 UI约束-007(列表/弹层排序交互规范:间隙高亮线标准,Tab 设置是否统一待老师评估);
|
||||
- docs/04-迭代记录/20-策略列设置拖拽排序/:迭代目标 + 技术实现方案(含实现纪要)+ 验收标准(10 项)。
|
||||
|
||||
## 遗留事项(待老师决策)
|
||||
|
||||
1. **test-r013-columns.mjs 期望过期**:COLUMN_META 现 10 列(R-015 新增 4 行情列默认隐藏),脚本期望仍为 6 列,
|
||||
默认列数相关 5 项断言失败。建议后续小迭代同步更新脚本期望(或重构为从 COLUMN_META 推导期望),本次未动。
|
||||
2. **Tab 设置整行高亮是否统一为间隙高亮线**:UI约束-007 列设置弹层已用间隙线,Tab 设置(UI约束-003)仍为整行高亮;
|
||||
老师反馈列设置「直观多了」——若 Tab 设置同样已存在「无法判断插入前/后」的观感问题,可同模式调整(独立小任务,本次未动)。
|
||||
|
||||
## 经验沉淀(候选)
|
||||
|
||||
- 排序类拖拽的落点指示,**间隙线(两行之间)比整行高亮更符合用户观感**:整行高亮只能表达「落在哪一行」,
|
||||
无法表达「插入该行前还是后」;主流列表/表格拖拽(如 Notion、表格列排序)均用间隙指示。
|
||||
- 服务端整表覆盖语义(updateStrategyColumns 整表提交)天然适配前端即时持久化,无需逐条增量接口;
|
||||
「保存按钮」在每次变更即写库后失去存在意义(本迭代据此移除)。
|
||||
- 回归脚本的期望值要随数据模型演进同步更新(test-r013-columns 教训:R-015 扩展 COLUMN_META 后脚本未跟随,
|
||||
留下 5 项静默失败,直到本迭代回归才暴露)。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 迭代目标:20-策略列设置拖拽排序
|
||||
|
||||
## 目标
|
||||
|
||||
策略 tab「列设置」弹层(ColumnSettingsPopover)的列排序交互从 **↑↓ 箭头点按** 升级为
|
||||
**鼠标选中后拖动排序**(原生 HTML5 DnD),并对齐 Tab 设置的即时持久化体验。
|
||||
|
||||
## 目标描述
|
||||
|
||||
- **背景**:策略 tab 的可配置列(基础数据列 + 自定义字段列)在「列设置」弹层中管理,排序目前依赖
|
||||
↑↓ 箭头(R-013 迭代 11 产物,当时刻意简化,代码注释自述「用箭头替代 DnD」)。老师反馈希望
|
||||
改成鼠标选中后拖动排序(2026-09-10)。
|
||||
- **结论**:复用 R-011 Tab 设置已验证的原生 HTML5 DnD 模式(draggable 行 + 落点高亮 + drop 重排),
|
||||
零第三方依赖;落点立即持久化(strategy-columns/update 整表覆盖),弹层去掉「保存」按钮只留「关闭」。
|
||||
- **范围**:仅改 src/client/views/ColumnSettingsPopover.jsx 单文件;服务端/API/表格渲染零改动;
|
||||
固定列(代码/名称/操作/展开箭头)不参与排序,维持现状。
|
||||
- **验收线**:拖拽可重排列顺序且落点高亮可见;drop 后立即写库(无需点保存);↑↓ 箭头仍可微调;
|
||||
勾选显隐立即生效;刷新后列顺序保持。对应需求 R-024(已定稿)。
|
||||
|
||||
## 目标讨论过程
|
||||
|
||||
1. 老师反馈(2026-09-10):策略 tab 列设计可否做成鼠标选中后拖动排序。
|
||||
2. AI 调研:Tab 设置(R-011)已实现同款原生 HTML5 DnD(SettingsSection.jsx),可零依赖复用;
|
||||
列设置弹层存在未使用的 dragKey 状态(原计划 DnD 的残留)。
|
||||
3. 登记 R-024(讨论中)→ 方案草案 A/B/C → 老师确认方案 A(弹层内拖拽);
|
||||
保存时机 / 箭头去留按 AI 推荐默认(立即持久化 + ↑↓ 保留兜底)。
|
||||
|
||||
## 对老师(项目主理人)的配合需求
|
||||
|
||||
- 无(纯前端交互优化,服务端零改动,验收走页面人工确认)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 验收标准:20-策略列设置拖拽排序
|
||||
|
||||
## 验收线(在哪里验收)
|
||||
|
||||
DSH Web GUI → 神之一手 → 任一策略 tab → 工具条「列设置」按钮 → 弹层。
|
||||
页面侧人工验收;服务端逻辑零改动,跑 scripts/test-r013-columns.mjs 回归确认无破坏。
|
||||
|
||||
## 验收方法(逐项操作)
|
||||
|
||||
| # | 操作 | 预期 |
|
||||
|---|---|---|
|
||||
| 1 | 打开「列设置」弹层,鼠标按住某行 ≡ 手柄(含整行)拖动到另一行附近移动鼠标 | 目标行上缘/下缘显示**主色间隙高亮线**(插入线),明确指示落点在目标列之前还是之后,非整行高亮 |
|
||||
| 2 | drop 后直接关闭弹层(不点任何保存) | 策略表格表头列顺序已按新顺序展示(立即持久化生效) |
|
||||
| 3 | 刷新页面 / 重新打开弹层 | 列顺序保持拖拽后的结果(写库成功) |
|
||||
| 4 | 点 ↑ / ↓ 箭头 | 仍可逐格微调,且立即持久化(关闭弹层后表格列顺序同步变化) |
|
||||
| 5 | 勾选/取消勾选某一列 | 显隐立即生效(关闭弹层后,表格对应列隐藏/出现) |
|
||||
| 6 | 拖动到目标行上半区 vs 下半区各松手一次 | 上半区 = 插入该目标列**之前**;下半区 = 插入该目标列**之后**(以落点线位置为准) |
|
||||
| 7 | 拖动期间把行拖回原位置或原地松手 | 无异常,列顺序不变(不重复写库) |
|
||||
| 8 | 固定列(代码/名称/操作) | 弹层列表不含固定列,不参与拖拽,恒显示(维持现状) |
|
||||
| 9 | 自定义字段列(某策略配了 configSchema) | 与基础列一样可拖拽排序、可勾选显隐 |
|
||||
| 10 | 保存中(快速连续拖动) | 无并发写异常;saving 期间拖拽/勾选禁用,Toast 提示保存结果 |
|
||||
|
||||
## 验收目标
|
||||
|
||||
- 功能正交:拖拽排序 / ↑↓ 微调 / 显隐勾选 三者并存且各自立即持久化;
|
||||
- 零回归:scripts/test-r013-columns.mjs 全绿、typecheck + build 通过;
|
||||
- 视觉一致性:≡ 手柄沿用 Tab 设置(UI约束-003)同套样式;落点为**间隙高亮线**(主色 token,UI约束-004 无硬编码色值);
|
||||
- 服务端零改动确认:git diff 无 src/api / src/settings.js 变更。
|
||||
|
||||
## 判定
|
||||
|
||||
- 老师页面人工确认 10 项全过 + 服务端回归全绿 ⇒ 迭代 20 验收通过,R-024 归档至 已完成/ 并更新索引实现状态。
|
||||
@@ -0,0 +1,84 @@
|
||||
# 技术实现方案:21-Tab设置排序间隙高亮统一
|
||||
|
||||
## 总体思路
|
||||
|
||||
把 SettingsSection.jsx 的 TabSettings 组件落点指示从「整行高亮(overId → 行背景 color-mix 12%)」
|
||||
改为「行间间隙高亮线(overPos { idx, half } → 目标行上缘/下缘 3px 主色线)」,与 ColumnSettingsPopover.jsx
|
||||
(R-024)逐点对齐;其余交互(≡ 手柄、开关、立即持久化、徽标、saving 禁用)不动。
|
||||
|
||||
## 改动点(单文件:src/client/views/SettingsSection.jsx,仅 TabSettings 组件)
|
||||
|
||||
### 1. 状态
|
||||
|
||||
- `overId`(整行高亮锚点)→ `overPos { idx, half }`(间隙线锚点行下标 + 上/下缘);
|
||||
- `dragId` 保留。
|
||||
|
||||
### 2. 悬停判定(与 R-024 全同)
|
||||
|
||||
```jsx
|
||||
const handleOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom';
|
||||
setOverPos({ idx: index, half });
|
||||
};
|
||||
```
|
||||
|
||||
### 3. drop(事件内按坐标重算,不依赖 state 闭包)
|
||||
|
||||
```jsx
|
||||
const handleDrop = async (e, index) => {
|
||||
e.preventDefault();
|
||||
if (!tabs || !dragId) { setDragId(null); setOverPos(null); return; }
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientY - rect.top < rect.height / 2;
|
||||
let to = before ? index : index + 1;
|
||||
const from = tabs.findIndex((x) => x.id === dragId);
|
||||
if (from < 0 || to < 0 || to > tabs.length) { setDragId(null); setOverPos(null); return; }
|
||||
if (to === from || to === from + 1) { setDragId(null); setOverPos(null); return; } // 原地跳过写库
|
||||
const next = tabs.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
const insertAt = to > from ? to - 1 : to;
|
||||
next.splice(insertAt, 0, moved);
|
||||
const ordered = next.map((x, i) => ({ ...x, order: i }));
|
||||
setDragId(null);
|
||||
setOverPos(null);
|
||||
await persist(ordered, '「' + moved.name + '」已移动');
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 行渲染(tr 上挂间隙线样式)
|
||||
|
||||
tr 为表格行,直接内嵌 div 会破坏表格结构(tr 只允许 td/th)——**间隙线以 td 为定位锚点绘制**:
|
||||
首选在每个 td 内各画一段 3px 高亮线(结构合规、视觉连续),实现纪要记录最终选型。
|
||||
|
||||
```jsx
|
||||
<tr
|
||||
key={t.id}
|
||||
draggable={!saving}
|
||||
onDragStart={(e) => { setDragId(t.id); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => handleOver(e, i)}
|
||||
onDrop={(e) => handleDrop(e, i)}
|
||||
onDragEnd={() => { setDragId(null); setOverPos(null); }}
|
||||
style={{ cursor: saving ? 'default' : 'grab' }}
|
||||
>
|
||||
<td style={{ position: 'relative', padding: '8px 6px' }}> {gapLine(i, 'td1')} ≡</td>
|
||||
...
|
||||
</tr>
|
||||
```
|
||||
|
||||
间隙线样式(与 R-024 全同):absolute、left/right 内缩 6、height 3、borderRadius 2、
|
||||
背景 primary token;top:0(插前)/ bottom:0(插后)。
|
||||
|
||||
## 边界与不做
|
||||
|
||||
- 服务端/API/表格列结构/持久化逻辑零改动;
|
||||
- 不动「策略分组」子 tab(该处无排序交互);
|
||||
- 不引入第三方 DnD 库。
|
||||
|
||||
## 回归
|
||||
|
||||
- pnpm typecheck + build(SettingsSection 变更需重编 web bundle);
|
||||
- 页面人工验收:Tab 设置拖动 → 间隙线位置正确(上=前/下=后)、整行高亮消失、drop 即存、
|
||||
拖回原位不重复写库、刷新页面后顺序保持。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 迭代目标:21-Tab设置排序间隙高亮统一
|
||||
|
||||
## 目标
|
||||
|
||||
设置页「Tab 设置」的分组 tab 拖拽排序落点指示,从**整行高亮**统一为**行间间隙高亮线**,
|
||||
与策略 tab 列设置弹层(R-024/UI约束-007)完全一致——明确指示插入目标行的前/后。
|
||||
|
||||
## 目标描述
|
||||
|
||||
- **背景**:迭代 20 复盘遗留第 2 项——R-024 将列设置弹层落点改为间隙高亮线后老师确认「直观多了」,
|
||||
但设置页 Tab 设置(R-011/UI约束-003)仍是整行高亮(overId → 目标行淡蓝底),无法判断插入前/后,
|
||||
两处观感不一致。2026-09-10 老师指令:Tab 设置也统一为间隙高亮线形式。
|
||||
- **范围**:仅改 src/client/views/SettingsSection.jsx 的 TabSettings 组件落点指示;表格结构、≡ 手柄、
|
||||
显隐开关、立即持久化(tabs/update)、徽标、saving 禁用全部维持不变;服务端/API 零改动。
|
||||
- **验收线**:拖动时目标行上缘/下缘显示主色间隙线(上半区=插前、下半区=插后);整行高亮消失;
|
||||
拖回原位不重复写库;drop 后立即持久化。对应需求 R-025(已定稿)。
|
||||
|
||||
## 目标讨论过程
|
||||
|
||||
1. 迭代 20 复盘(2026-09-10)列遗留第 2 项:Tab 设置整行高亮是否统一为间隙线,留待老师评估。
|
||||
2. 老师指令(2026-09-10):设置页分组 tab 排序也改上面(列设置弹层)的拖动高亮形式 → 登记 R-025 已定稿,
|
||||
迭代 21 实施。
|
||||
|
||||
## 对老师(项目主理人)的配合需求
|
||||
|
||||
- 无(纯前端交互统一,服务端零改动,验收走页面人工确认)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 验收标准:21-Tab设置排序间隙高亮统一
|
||||
|
||||
## 验收线(在哪里验收)
|
||||
|
||||
DSH Web GUI → 设置页 → 「Tab 设置」子 tab → 拖动任一标签行排序。
|
||||
页面侧人工验收;服务端逻辑零改动。
|
||||
|
||||
## 验收方法(逐项操作)
|
||||
|
||||
| # | 操作 | 预期 |
|
||||
|---|---|---|
|
||||
| 1 | 打开「Tab 设置」,按住某行 ≡ 手柄(含整行)拖动到另一行附近移动鼠标 | 目标行上缘/下缘显示**主色间隙高亮线**(插入线),明确指示落点在目标 tab 之前还是之后;原整行淡蓝高亮消失 |
|
||||
| 2 | 拖动到目标行上半区 vs 下半区各松手一次 | 上半区 = 插入该 tab **之前**;下半区 = 插入该 tab **之后**(以落点线位置为准) |
|
||||
| 3 | drop 后落点立即生效 | Toast「已移动」,刷新页面后 tab 顺序保持(tabs/update 已写库) |
|
||||
| 4 | 拖动期间把行拖回原位置或原地松手 | 无异常,顺序不变,且不触发重复写库 |
|
||||
| 5 | 既有交互回归 | ≡ 手柄、显示/隐藏开关、内置/策略徽标、saving 期间拖拽禁用——全部维持原行为 |
|
||||
| 6 | 策略分组子 tab | 无排序交互,不受影响(仍为 新增/重命名/删除) |
|
||||
|
||||
## 验收目标
|
||||
|
||||
- 功能对准:Tab 设置落点与列设置弹层(R-024)**同一套间隙线交互**,观感一致;
|
||||
- 零回归:既有 tabs 持久化(整表 order + visible)、显隐开关、策略改名联动 name join 不受影响;
|
||||
- typecheck + build 通过;无新增硬编码色值(UI约束-004)。
|
||||
|
||||
## 判定
|
||||
|
||||
- 老师页面人工确认 6 项全过 + build 通过 ⇒ 迭代 21 验收通过,R-025 归档至 已完成/ 并更新索引实现状态。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 需求:R-021 神之一手数据加入会话(对象目录 + 添加机制;复盘为第一用例)· 已定稿(部分暂定)
|
||||
|
||||
> 登记:2026-09-08 | 来源:老师指令(原 R-019 + R-021 合并演进)| 状态:**已定稿(暂定项见文末)**
|
||||
> 归属:迭代 17-策略会话(Phase 0)| 计划:PLAN-017 | 实现状态:已立项(设计阶段)
|
||||
|
||||
## 需求本质(2026-09-08 再定调,老师)
|
||||
|
||||
> **总体是:会话(agent)可以获取"神之一手"的数据;复盘只是 agent 获取到对应数据后的一种用法。本需求要确定两件事:① 数据怎么添加到会话 ② 哪些数据可以加。**
|
||||
> (策略会话、粒度复盘、未来的盘中问答/分析 都是同一能力的消费方式——主干 = 「神之一手数据 → 会话」能力。)
|
||||
|
||||
## 一、数据怎么添加到会话(机制)——暂定 2026-09-08
|
||||
|
||||
| 维度 | 暂定结论(AI 建议,老师"暂定") | 开放注记 |
|
||||
|---|---|---|
|
||||
| 触发动因 | **A:人挑对象加入**——在数据展示处/会话内选一个"数据对象",生成它的数据上下文进会话 | B(agent 按需取数工具)不采纳,维持 R-019"预注入不挂工具"拍板;日后盘中问答需要实时取数时再独立讨论 |
|
||||
| 注入形态 | 每个对象 = 一条只读「数据上下文」(折叠条目/引用),含快照时间;同对象重复加 = 新快照追加(不覆盖旧),模型以最近为准 | 通道形态待宿主技术调研 |
|
||||
| 数据时效 | 注入"加入那一刻"的只读快照;要最新 = 重新添加 | — |
|
||||
|
||||
## 二、哪些数据可以加(对象目录)——候选目录,首批范围暂定
|
||||
|
||||
### 神之一手数据对象目录(候选全量)
|
||||
|
||||
账户:账户资金/资产、全部持仓(对账单快照)| 策略:策略(分组下持仓与交易)| 持仓:当前持仓行、历史(已清仓)持仓| 轮次:某一轮交易周期(round,待建模)| 交易:单笔委托/成交| 关系视图:持仓 ↔ 交易| 行情:单 code 现价(随行,不单独成对象)| 汇总:策略汇总/未分配/同步健康 | 未来:决策日志/统计/复盘记录(Phase 1/3)
|
||||
|
||||
### 首批范围(复盘闭环所需,暂定草案,待老师细调)
|
||||
| # | 对象 | 加入后 agent 拿到的数据上下文(草案) | 为什么复盘需要 |
|
||||
|---|---|---|---|
|
||||
| P1 | 策略 | 策略定义 + 当前持仓 + 关联交易聚合摘要 | 整策略复盘/会话依据 |
|
||||
| P2 | 持仓(当前/历史) | 持仓完整档案:基本信息+自定义字段+建仓至今买卖链+现价盈亏+(历史=清仓信息) | 复盘一个持仓 |
|
||||
| P3 | 持仓↔交易关系 | 该持仓全部关联委托/成交(按 holding 聚合) | 追溯买卖过程 |
|
||||
| P4 | 单笔委托 | 时间/方向/量/价/费用/归属 + 该持仓上下文 | 复盘某次动作 |
|
||||
| P5 | 轮次 round | 一轮建→平 全动作+结果(需本轮引入 round 建模) | 复盘最小单元(网格/做T 一轮) |
|
||||
| P6 | 行情随行 | 对象行内现价/涨跌等(不单独成对象;历史 K 线不在本期数据域) | 复盘需当前价对照 |
|
||||
|
||||
**候选后补**:账户资产 / 全部持仓对账单 / 策略汇总 / 未分配 / 同步健康(供 Phase 1 盘中辅助与 Phase 2 日结)。
|
||||
|
||||
## 三、用例视角(复盘仅是用法之一)
|
||||
|
||||
- 复盘 = 挑对象(P1-P6 任意组合,通常 P2/P3/P4/P5)→ 数据上下文进会话 → agent 基于事实线引导复盘 → 结论留会话;
|
||||
- 其他用例(未来消费方式):盘中状态问答、策略分析、计划讨论——同一"对象目录 + 添加机制"。
|
||||
|
||||
## 定稿边界(合并两轮结论)
|
||||
|
||||
**做**:数据对象目录 + 添加机制(人挑对象 → 只读数据上下文注入会话);首批对象数据上下文生成;折叠条目呈现;换/刷新/叠加;round 建模(若 P5 首批保留)。
|
||||
|
||||
**不做**:DSH 宿主工作区;账本写与交易执行;agent 按需取数工具(暂定);复盘结论落库/统计/决策日志实现(Phase 1/2/3,预留模型位);历史 K 线注入;通用数据视图(全部持仓/交易记录)作为对象目录外的会话依据特例——(对象目录已含"全部持仓",是否启用由首批范围定)。
|
||||
|
||||
## 待定/开放项
|
||||
1. 首批对象范围与 P1-P6 取舍(老师可增删,含是否本期引入 round=P5);
|
||||
2. 宿主注入通道形态(技术调研);
|
||||
3. 各对象"数据上下文"的精确字段规格(服务端实现细节,技术方案阶段);
|
||||
4. 会话侧呈现细节(UI 交互设计延后,老师先前指令)。
|
||||
|
||||
## 关联
|
||||
- 迭代 17-策略会话(Phase 0)| R-010(持仓↔交易关系载体)| 终极目标-002/006/007
|
||||
- 未来:Phase 1 决策留痕/盘中辅助、Phase 2 日结统计、Phase 3 复盘库(产品逻辑设计 §0.5)
|
||||
- 归档:R-019 已并入本需求(已完成/R-019.md 为讨论史存档)
|
||||
|
||||
## 验收
|
||||
**(技术方案定稿后补入 04-迭代记录/17-策略会话/验收标准.md)**
|
||||
@@ -0,0 +1,37 @@
|
||||
# 需求:R-022 QMT 连接健康灯自适应探测(Bridge 启动/恢复后快速回绿)· 讨论中
|
||||
|
||||
> 登记:2026-09-09 | 来源:老师反馈(会话头部 QMT 指示灯)| 状态:**讨论中(AI 提议定稿,待老师确认)**
|
||||
> 归属:迭代 19-指示灯状态自适应探测(R-022:QMT 健康灯)| 实现状态:已实现(待验收)
|
||||
|
||||
## 症状(老师反馈,2026-09-09)
|
||||
|
||||
启动 QMT Bridge 后,会话头部四个指示灯中「持仓数据 / 行情数据 / MCP」几秒内自动点亮,
|
||||
唯独「QMT连接」灯一直不亮(停留在红/灰态)——「QMT 连接状态自动检查好像没有生效」。
|
||||
|
||||
## 根因
|
||||
|
||||
QmtHealthMonitor 只在**插件启动时 + 每 5 分钟**探测一次激活 Bridge /health(2026-09-01 迭代 04 定的
|
||||
「服务端 5 分钟定时探测 + 缓存」机制),结果缓存内存;前端 10s 轮询 sync-status 只读缓存、不触发探测。
|
||||
其余三灯靠高频自愈回路:PositionSync 10s / QuoteSync 5s 定时拉 Bridge、MCP 客户端自带失败重连,
|
||||
Bridge 恢复后秒级回绿。唯独健康缓存要等满 5 分钟下一次探测——Bridge 在两次探测间启动/恢复时,
|
||||
灯长期停留在上一次失败结果,观感即「自动检查没生效」(反向同理:Bridge 中途宕机,灯最多 5 分钟不转红)。
|
||||
|
||||
## 方案(AI 提议,老师拍板确认)
|
||||
|
||||
**自适应探测节奏(owner = QmtHealthMonitor,单点改动)**:
|
||||
|
||||
- 健康 → 每 5 分钟探测一次(**稳态,维持原设计低开销**);
|
||||
- 异常/未知(含从未探测成功)→ **每 10 秒快速重试**(与前端 sync-status 10s 轮询对齐,
|
||||
与持仓/行情 5-10s 恢复节奏一致)——Bridge 启动后灯 ≤20s 自动回绿。
|
||||
|
||||
**边界(不做)**:前端读缓存秒回不变;API/前端零改动;健康稳态间隔不放宽不放窄(5 分钟不变);
|
||||
不新增配置项(间隔为常量,测试经构造参数注入);不做 read-path 触发探测(定时器已覆盖,避免耦合)。
|
||||
|
||||
## 决策记录
|
||||
|
||||
| 项 | 结论 | 讨论 |
|
||||
|---|---|---|
|
||||
| 异常重试间隔 | 10s | 与前端 10s 轮询对齐;5s 会翻倍失败探测开销(每次失败探测最长挂 5s 超时) |
|
||||
| 健康稳态间隔 | 5 分钟维持 | 原设计为省探测开销;异常态才快速重试,健康态不空转 |
|
||||
| 防并发/调度 | setTimeout 链 + _inFlight 去重 | 沿用原防并发语义;手动探测/热切换共用同一节奏重排 |
|
||||
| baseUrl 解析异常 | 落 healthy:false 缓存走快速重试 | 原实现解析在 try 外,异常会丢缓存导致灯灰且不重试 |
|
||||
@@ -0,0 +1,36 @@
|
||||
# 需求:R-023 MCP 状态灯自适应探测(MCP 服务恢复后自动回绿)· 讨论中
|
||||
|
||||
> 登记:2026-09-09 | 来源:老师反馈(R-022 之后实测 MCP 灯同样不自动恢复)| 状态:**讨论中(AI 提议定稿,待老师确认)**
|
||||
> 归属:迭代 19-指示灯状态自适应探测 | 实现状态:已实现(待验收)
|
||||
|
||||
## 症状(老师反馈,2026-09-09)
|
||||
|
||||
QMT 健康灯修复后复查发现:会话头部 MCP 指示灯也存在同样的「状态不自动恢复」——
|
||||
MCP 服务(QMT Bridge /mcp)启动/恢复后,MCP 灯停留在上次失败态,不会自动回绿。
|
||||
|
||||
## 根因
|
||||
|
||||
QmtMcpManager 的**状态缓存只在 挂载 / 配置切换 / 手动探测(点击「检测 MCP」)时刷新**,无任何定时回路;
|
||||
sync-status 端点每 10s 读的只是这份缓存。dsh-mcp-client 自带的 reconnect 只恢复**真实连接**
|
||||
(fiber 层),并不刷新插件自己的状态缓存——所以「连接恢复了、灯还是红的」。
|
||||
|
||||
## 方案(AI 提议,与 R-022 同模式,owner = QmtMcpManager)
|
||||
|
||||
**结果驱动自适应探测**(对齐 QmtHealthMonitor):
|
||||
|
||||
- connected → 每 5 分钟探测一次(稳态,低开销);
|
||||
- 非 connected(error/异常)→ **每 10 秒快速重试**——MCP 服务恢复后灯 ≤20s 自动回绿;
|
||||
- 无激活连接(url 为空)不探测不调度;dispose() 后停止调度;
|
||||
- 探测仍走 SDK 独立只读握手(Client connect → initialize + tools/list),与 dsh-mcp-client 自管连接互不干扰。
|
||||
|
||||
**边界(不做)**:不接管/不替代 dsh-mcp-client 的重连(那是真实连接层,保持现状 Q5);
|
||||
不新增配置项(间隔为常量,测试经构造参数注入);前端/API 零改动(getStatus 读缓存语义不变)。
|
||||
|
||||
## 决策记录
|
||||
|
||||
| 项 | 结论 | 讨论 |
|
||||
|---|---|---|
|
||||
| 非连接态重试间隔 | 10s | 与前端 sync-status 10s 轮询、R-022 QMT 灯一致;MCP 探测最坏挂 8s 超时,快失败场景成本可忽略 |
|
||||
| 已连接稳态间隔 | 5 分钟 | 与 R-022 一致;MCP 握手比重(initialize+tools/list),健康态不空转 |
|
||||
| 调度停止条件 | 无激活连接 / dispose / unmount | 无 url 时探测早退置 disabled 并取消定时器,避免空转 |
|
||||
| 并发防护 | _probing 去重(同 QmtHealthMonitor._inFlight) | 定时器与手动/热切换探测可能撞车 |
|
||||
@@ -0,0 +1,40 @@
|
||||
# 需求:R-025 设置页 Tab 设置排序落点统一为间隙高亮线 · 已定稿
|
||||
|
||||
> 登记:2026-09-10 | 来源:老师指令(迭代 20 复盘遗留第 2 项确认)| 状态:**已定稿(2026-09-10 老师指令)**
|
||||
> 归属:迭代 21-Tab设置排序间隙高亮统一 | 实现状态:未开始
|
||||
|
||||
## 诉求(老师,2026-09-10)
|
||||
|
||||
> 神之一手设置页的分组 tab 排序(Tab 设置),也改一下上面(列设置弹层)的拖动高亮显示形式。
|
||||
|
||||
## 背景
|
||||
|
||||
- R-024/迭代 20 列设置弹层拖拽落点已从整行高亮改为**行间间隙高亮线**(UI约束-007),老师确认「直观多了」;
|
||||
- 复盘遗留第 2 项:设置页「Tab 设置」的排序仍是整行高亮(overId → 行淡蓝底,UI约束-003),存在同样的
|
||||
「无法判断插入前/后」观感问题;老师指令统一为间隙线形式。
|
||||
|
||||
## 定稿结论(2026-09-10 老师指令)
|
||||
|
||||
设置页「Tab 设置」排序落点从整行高亮改为**行间间隙高亮线**(复用 UI约束-007 / R-024 已验证模式):
|
||||
|
||||
- 拖动悬停时按鼠标在目标行内的 Y 坐标(上半/下半)判定,间隙线画在目标行上缘(插其前)/下缘(插其后);
|
||||
- drop 事件内按坐标重算插入位置(不依赖 state 闭包);拖回原位置(to===from 或 to===from+1)跳过写库;
|
||||
- 保留 onDragEnd 清理残留;整行高亮背景(color-mix 12%)移除;
|
||||
- 其余维持不变:≡ 手柄、显隐开关、落点立即持久化(tabs/update)、「刷新页面后生效」提示、内置/策略徽标、
|
||||
saving 期间禁用拖拽。
|
||||
|
||||
## 决策记录
|
||||
|
||||
| 项 | 结论 | 讨论 |
|
||||
|---|---|---|
|
||||
| 范围 | 仅 Tab 设置(SettingsSection.jsx TabSettings)落点指示 | 老师指令;不动表格结构、不动持久化、不动开关 |
|
||||
| 落点形式 | 行间间隙高亮线(上缘=前/下缘=后) | 与 R-024 弹层全同,UI约束-007 已定义 |
|
||||
| 判定 | 目标行内 Y 坐标上下半区 | 与 R-024 全同 |
|
||||
| 无操作 | to===from 或 to===from+1 跳过写库 | 复用 R-024 数学式 |
|
||||
| 服务端/API | 零改动 | tabs/update 整表语义不变 |
|
||||
| 约束更新 | UI约束-003 变更:落点从整行高亮改间隙线;UI约束-007 补「Tab 设置已统一」 | 2026-09-10 老师指令 |
|
||||
|
||||
## 关联
|
||||
|
||||
- 上游:UI约束-007(排序交互间隙线标准)、R-024(同款模式实证)、迭代 20 复盘遗留第 2 项;
|
||||
- 迭代 20 复盘遗留第 1 项(test-r013-columns 期望过期)不在本需求范围。
|
||||
@@ -3,6 +3,12 @@
|
||||
> 登记:2026-09-02 | 来源:架构梳理讨论 + 老师指令 | 状态:**已定稿(老师逐项拍板 4 问)**
|
||||
> 归属:迭代 12 | 计划:PLAN-013
|
||||
|
||||
|
||||
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:12-持仓内存快照(持仓内存快照)
|
||||
> 讨论记录索引:R-014.md 正文;实现细节见 04-迭代记录/12-持仓内存快照/
|
||||
> 归档路径:05-需求池/已完成/R-014.md
|
||||
> 迭代状态标记:已实现(迭代 12,回归 34/34 + typecheck + build 通过)→ 已归档(2026-09-08 老师归档指令确认验收)
|
||||
|
||||
## 需求描述
|
||||
|
||||
原实盘持仓数据在**每次请求时穿透 QMT**(`PositionManager.getAllPositions()` 实时拉 `/trade/positions`),带来两个问题:
|
||||
@@ -3,6 +3,12 @@
|
||||
> 登记:2026-09-02 | 来源:架构演进讨论(老师逐项拍板)| 状态:**已定稿**
|
||||
> 归属:迭代 13 | 计划:PLAN-014
|
||||
|
||||
|
||||
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:13-盘口内存快照(盘口数据内存化+指示灯)
|
||||
> 讨论记录索引:R-015.md 正文;实现细节见 04-迭代记录/13-盘口内存快照/
|
||||
> 归档路径:05-需求池/已完成/R-015.md
|
||||
> 迭代状态标记:已实现(迭代 13,test-quote-sync 全绿 + 存量回归通过)→ 已归档(2026-09-08 老师归档指令确认验收)
|
||||
|
||||
## 需求描述
|
||||
|
||||
持仓数据已完成「内存快照统一管理」(R-014/迭代 12)。本轮把**盘口(市场行情)数据**收敛为同一模式:
|
||||
@@ -3,6 +3,12 @@
|
||||
> 登记:2026-09-07 | 来源:老师指令(优化功能需求讨论)| 状态:**已定稿(2026-09-07,Q1-Q7 老师拍板)**
|
||||
> 归属:迭代 14 | 计划:PLAN-015 | 实现状态:**已实现(2026-09-07,待老师人工验收)**
|
||||
|
||||
|
||||
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:14-策略tab历史持仓展示(策略 tab 历史持仓展示)
|
||||
> 讨论记录索引:R-016.md 正文;实现细节见 04-迭代记录/14-策略tab历史持仓展示/
|
||||
> 归档路径:05-需求池/已完成/R-016.md
|
||||
> 迭代状态标记:已实现(迭代 14)→ 已归档(2026-09-08 老师归档指令确认验收)
|
||||
|
||||
## 需求描述(老师原始诉求)
|
||||
|
||||
**两个策略 tab**(做T / 网格超市)**title 旁边**添加以下功能:
|
||||
@@ -4,6 +4,12 @@
|
||||
> 归属:迭代 15 | 计划:PLAN-016 | 实现状态:**已实现(2026-09-07,待老师人工验收)**
|
||||
> 前置:R-016「关联观察」所列问题的正式立项(清仓后当日委托无法通过下拉关联到对应 holding,2026-09-07 大连热电实际发生——盘中手动归属后又被清为未关联,下拉已无手动做T候选)。
|
||||
|
||||
|
||||
> **归档头(2026-09-08)** | 需求状态:已完成 | 归档日期:2026-09-08 | 实现迭代:15-归属候选纳入清仓持仓(归属候选纳入近期清仓持仓)
|
||||
> 讨论记录索引:R-017.md 正文;实现细节见 04-迭代记录/15-归属候选纳入清仓持仓/
|
||||
> 归档路径:05-需求池/已完成/R-017.md
|
||||
> 迭代状态标记:已实现(迭代 15)后被 R-018 语义取代退役 → 已归档(2026-09-08)
|
||||
|
||||
## 需求描述
|
||||
|
||||
交易记录 tab 的归属候选(orders/attribution-candidates)只回**当前持仓**——清仓后(幽灵清仓最快约 30s 转历史)当日委托在下拉中失去对应策略/持仓锚点,无法补关联。本轮:**候选 = 该 code 的当前持仓 + 近 7 天清仓的持仓**,清仓行带标记供 UI 区分展示。
|
||||
@@ -3,6 +3,11 @@
|
||||
> 登记:2026-09-08 | 来源:老师指令(新需求)| 状态:**已定稿(2026-09-08,F1/F2 老师确认)**
|
||||
> 归属:迭代 17(待立项)| 计划:待出(PLAN-017)| 实现状态:未开始
|
||||
|
||||
|
||||
> **归档头(2026-09-08)** | 需求状态:已完成(并入合并需求)| 归档日期:2026-09-08
|
||||
> 合并承接:**R-021(策略会话复盘)**——老师 2026-09-08 指令将 R-019 与 R-021 合并、逻辑整合入 R-021;本文件为 R-019 线讨论史存档(Q1-Q4/R2-Q1..Q4/R3/F1-F2)
|
||||
> 归档路径:05-需求池/已完成/R-019.md
|
||||
|
||||
## 需求描述(老师原始诉求,原话)
|
||||
|
||||
> **新需求,让 AI 会话可以以策略为 Workspace。**
|
||||
@@ -0,0 +1,46 @@
|
||||
# 需求:R-024 策略 tab 列设置改为拖拽排序 · 已完成
|
||||
|
||||
> 归档日期:2026-09-10 | 需求状态:已完成 | 实现迭代:[迭代 20-策略列设置拖拽排序](../04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md)
|
||||
> 讨论记录索引:本文档(定稿 + 变更记录);登记:2026-09-10 | 来源:老师反馈(策略 tab 列设计细节优化)
|
||||
> 实现状态:**已实现(迭代 20 验收通过,2026-09-10 老师页面确认)**
|
||||
|
||||
## 诉求(老师,2026-09-10)
|
||||
|
||||
> 细节优化,策略 tab 下的列设计,可否做成鼠标选中后、**拖动排序**的形式?
|
||||
|
||||
## 现状
|
||||
|
||||
策略 tab「列设置」弹层(ColumnSettingsPopover,R-013 迭代 11 实现)管理可配置列(基础数据列 + 自定义字段列):
|
||||
- 显隐:复选框勾选;
|
||||
- 排序:**↑↓ 箭头按钮**(连续点按移动),保存按钮提交整份列配置(one-divine-lot/strategy-columns/update);
|
||||
- 代码注释原话:「同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥」——当时刻意简化。
|
||||
|
||||
## 定稿结论(2026-09-10 老师确认方案 A)
|
||||
|
||||
**方案 A:列设置弹层内拖拽排序(原生 HTML5 DnD,复用 R-011 Tab 设置已验证模式)**
|
||||
|
||||
- 弹层每行加 **≡ 拖拽手柄**(整行 draggable),按住拖动到目标行松手即重排;落点行高亮提示,视觉沿
|
||||
Tab 设置(UI约束-003)同一套:color-mix(primary 12%) 淡底。
|
||||
- **落点立即持久化**(对齐 Tab 设置体验):每次 drop 调一次 one-divine-lot/strategy-columns/update
|
||||
整表提交(含不可见列保位置);点按手势(勾选显隐 / ↑↓ 微调)同步立即持久化 → **弹层去掉「保存」按钮,
|
||||
只留「关闭」**,Toast 反馈保存结果。
|
||||
- **↑↓ 箭头保留作兜底**(精准微调场景,与拖拽并存)。
|
||||
- 固定列(代码/名称/操作/展开箭头)不参与排序,恒显示(维持现状)。
|
||||
- 边界:不改服务端 API(strategy-columns/update 已是整表覆盖语义,天然兼容);核心改动在
|
||||
ColumnSettingsPopover.jsx 单文件;显隐勾选交互不变。
|
||||
|
||||
## 决策记录
|
||||
|
||||
| 项 | 结论 | 讨论 |
|
||||
|---|---|---|
|
||||
| 拖拽范围 | 方案 A:弹层内拖拽(≡ 手柄) | 老师 2026-09-10 确认;方案 C(表头直拖)改动大、需兼容百分比排序点击,暂不做 |
|
||||
| 持久化时机 | 落点立即持久化(每次变更即写库) | 对齐 Tab 设置(UI约束-003)体验;strategy-columns/update 为整表覆盖语义,天然支持 |
|
||||
| ↑↓ 箭头 | 保留作兜底 | 与拖拽并存,兼顾精准微调;不增加额外依赖 |
|
||||
| 保存按钮 | 移除(改「关闭」) | 所有变更即时持久化后按钮失去意义,避免「忘了点保存」 |
|
||||
| 服务端 | 零改动 | strategy-columns/update 已是整表覆盖 |
|
||||
|
||||
## 变更记录(追加式)
|
||||
|
||||
| 日期 | 变更 | 原因 |
|
||||
|---|---|---|
|
||||
| 2026-09-10 | 拖拽落点提示从**整行高亮**改为**行间(两列之间)间隙高亮线**,指示精确插入位置(前/后) | 老师反馈:整行高亮只能看出落在哪一行,无法判断会插入目标列的前面还是后面;间隙高亮是主流列表拖拽交互(Tab 设置的整行高亮是否同样调整待老师评估,见 UI约束-007) |
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* QmtHealthMonitor 自适应探测 回归测试(R-022 / 迭代 19,2026-09-09)
|
||||
*
|
||||
* 运行:node scripts/test-health-monitor.mjs
|
||||
* 隔离:纯内存 mock(mock fetch + mock settings scope),用短间隔参数测真实 setTimeout 调度。
|
||||
* 背景:原实现固定 5 分钟探测,QMT Bridge 恢复后指示灯要等满 5 分钟(老师反馈「自动检查没生效」);
|
||||
* 本测试守护「异常/未知 → 快速重试,健康 → 稳态间隔」的自适应节奏。
|
||||
*/
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(cond, name) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + name); }
|
||||
else { failed++; console.error(' ✗ ' + name); }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const { QmtHealthMonitor } = await import('../src/data-source/QmtHealthMonitor.js');
|
||||
|
||||
/** 可编程 settings scope(resolveActiveBaseUrl 依赖 scope.get() → qmtConnections) */
|
||||
function mockSettings(baseUrl) {
|
||||
return {
|
||||
get: () => ({
|
||||
qmtConnections: {
|
||||
list: [{ id: 'c1', name: 'bridge', baseUrl, order: 1 }],
|
||||
activeId: 'c1',
|
||||
defaultId: '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const BRIDGE_URL = 'http://bridge.test:8610';
|
||||
|
||||
/** 可编程 fetch mock:bridgeUp=false 时抛连接错误 */
|
||||
function mockFetch() {
|
||||
const calls = [];
|
||||
let up = true;
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push({ url: String(url), t: Date.now() });
|
||||
if (!up) throw new Error('fetch failed: ECONNREFUSED bridge down');
|
||||
return { ok: true, status: 200 };
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
set up(v) { up = v; },
|
||||
get up() { return up; },
|
||||
};
|
||||
}
|
||||
|
||||
const RETRY_MS = 40;
|
||||
const INTERVAL_MS = 300;
|
||||
|
||||
console.log('[1] 启动即探测 + 缓存读取(健康)');
|
||||
{
|
||||
const fetch = mockFetch();
|
||||
const monitor = new QmtHealthMonitor({
|
||||
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
|
||||
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
await monitor.probe(); // 等价 start() 的立即探测
|
||||
ok(fetch.calls.length === 1, '启动探测发起 1 次');
|
||||
ok(fetch.calls[0].url === BRIDGE_URL + '/health', '探测地址 = 激活 baseUrl + /health');
|
||||
const st = monitor.getStatus();
|
||||
ok(st.healthy === true && st.fromCache === true, '缓存 healthy=true 且 fromCache=true');
|
||||
await monitor.stop();
|
||||
}
|
||||
|
||||
console.log('[2] 异常 → 快速重试自动恢复(核心回归:Bridge 启动后灯 ≤ 重试间隔回绿)');
|
||||
{
|
||||
const fetch = mockFetch();
|
||||
fetch.up = false;
|
||||
const monitor = new QmtHealthMonitor({
|
||||
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
|
||||
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
monitor.start();
|
||||
await sleep(RETRY_MS * 3); // 快速重试窗口内应已多次探测
|
||||
ok(fetch.calls.length >= 2, '异常态多次快速重试(' + fetch.calls.length + ' 次 ≥ 2)');
|
||||
ok(monitor.getStatus().healthy === false, '异常缓存 healthy=false');
|
||||
|
||||
fetch.up = true; // 模拟 QMT Bridge 启动
|
||||
const before = fetch.calls.length;
|
||||
await sleep(RETRY_MS * 4); // 等下一个快速重试命中
|
||||
const st = monitor.getStatus();
|
||||
ok(fetch.calls.length > before, '恢复后被自动重探测到');
|
||||
ok(st.healthy === true, '恢复后缓存 healthy=true(灯将回绿,未等 5 分钟稳态间隔)');
|
||||
await monitor.stop();
|
||||
}
|
||||
|
||||
console.log('[3] 健康稳态:回落到长间隔,不空转');
|
||||
{
|
||||
const fetch = mockFetch();
|
||||
const monitor = new QmtHealthMonitor({
|
||||
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
|
||||
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
monitor.start();
|
||||
await sleep(RETRY_MS * 4); // 小于稳态间隔:健康态不应按重试节奏空转
|
||||
ok(fetch.calls.length === 1, '健康态在稳态间隔内只探测 1 次(无快速空转)');
|
||||
await sleep(INTERVAL_MS + RETRY_MS * 2); // 跨过稳态间隔
|
||||
ok(fetch.calls.length === 2, '跨过稳态间隔后又探测 1 次(共 2 次)');
|
||||
await monitor.stop();
|
||||
}
|
||||
|
||||
console.log('[4] stop() 后停止自动调度');
|
||||
{
|
||||
const fetch = mockFetch();
|
||||
fetch.up = false;
|
||||
const monitor = new QmtHealthMonitor({
|
||||
runtime: { settings: mockSettings(BRIDGE_URL), dataSource: { baseUrl: BRIDGE_URL } },
|
||||
intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
monitor.start();
|
||||
await sleep(RETRY_MS * 2);
|
||||
const before = fetch.calls.length;
|
||||
monitor.stop();
|
||||
fetch.up = true;
|
||||
await sleep(RETRY_MS * 4);
|
||||
ok(fetch.calls.length === before, 'stop 后无自动探测');
|
||||
}
|
||||
|
||||
console.log('----');
|
||||
console.log(passed + ' passed, ' + failed + ' failed');
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* QmtMcpManager 状态自适应探测 回归测试(R-023 / 迭代 19,2026-09-09)
|
||||
*
|
||||
* 运行:node scripts/test-mcp-status.mjs
|
||||
* 说明:SDK 握手(initialize/tools.list)依赖真实网络端点,无法纯内存 mock;
|
||||
* 本测试守护的是【调度机制】——probe 结果驱动 setTimeout 自适应链:
|
||||
* connected → intervalMs 稳态;非 connected → retryMs 快速重试;
|
||||
* 无激活连接不调度;dispose 停止调度。
|
||||
* 用 stub probe(记录调用 + 翻转状态 + 触达真实 _scheduleNext)验证自动恢复数据流;
|
||||
* 无激活连接场景用真实 probe(早退路径不触 SDK)。
|
||||
*/
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(cond, name) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + name); }
|
||||
else { failed++; console.error(' ✗ ' + name); }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const { QmtMcpManager } = await import('../src/mcp/QmtMcpManager.js');
|
||||
|
||||
const RETRY_MS = 50;
|
||||
const INTERVAL_MS = 350;
|
||||
const BRIDGE_URL = 'http://bridge.test:8610';
|
||||
|
||||
/** settings scope:baseUrl 传 '' = 无激活连接 */
|
||||
function mockSettings(baseUrl) {
|
||||
return {
|
||||
get: () => ({
|
||||
qmtConnections: {
|
||||
list: baseUrl ? [{ id: 'c1', name: 'bridge', baseUrl, order: 1 }] : [],
|
||||
activeId: baseUrl ? 'c1' : '',
|
||||
defaultId: '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const quietLogger = { info() {}, warn() {}, debug() {} };
|
||||
|
||||
console.log('[1] 无激活连接:置 disabled 且不调度(真实 probe 早退,不触 SDK)');
|
||||
{
|
||||
const mgr = new QmtMcpManager({
|
||||
ctx: {}, runtime: { settings: mockSettings(''), dataSource: {} },
|
||||
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
let probeCalls = 0;
|
||||
const orig = mgr.probe.bind(mgr);
|
||||
mgr.probe = async (u) => { probeCalls++; return orig(u); };
|
||||
await mgr.start();
|
||||
ok(mgr.getStatus().state === 'disabled', '无激活连接 → 状态 disabled');
|
||||
await sleep(RETRY_MS * 4);
|
||||
ok(probeCalls === 1, '无激活连接无定时探测(仅 start 一次)');
|
||||
await mgr.dispose();
|
||||
}
|
||||
|
||||
console.log('[2] 异常 → 快速重试自动恢复(核心回归:MCP 服务恢复后灯 ≤ 重试间隔回绿)');
|
||||
{
|
||||
const mgr = new QmtMcpManager({
|
||||
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
|
||||
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
let bridgeUp = false;
|
||||
let probeCalls = 0;
|
||||
mgr.probe = async () => {
|
||||
probeCalls++;
|
||||
mgr.status = bridgeUp
|
||||
? { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() }
|
||||
: { state: 'error', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 0, error: 'ECONNREFUSED', latencyMs: 3, checkedAt: Date.now() };
|
||||
mgr._scheduleNext(); // 等价真实 probe 尾部
|
||||
return mgr.status;
|
||||
};
|
||||
await mgr.probe(); // 启动首次探测(Bridge 未启动)
|
||||
ok(mgr.getStatus().state === 'error', 'Bridge 未启动 → 状态 error');
|
||||
await sleep(RETRY_MS * 3);
|
||||
ok(probeCalls >= 3, '异常态按 retryMs 快速重试(' + probeCalls + ' 次 ≥ 3)');
|
||||
|
||||
bridgeUp = true; // 模拟 MCP 服务启动
|
||||
await sleep(RETRY_MS * 3);
|
||||
ok(mgr.getStatus().state === 'connected', '恢复后被自动探测到 → connected(灯将回绿,未等 5 分钟稳态间隔)');
|
||||
await mgr.dispose();
|
||||
}
|
||||
|
||||
console.log('[3] 已连接稳态:回落到长间隔,不空转');
|
||||
{
|
||||
const mgr = new QmtMcpManager({
|
||||
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
|
||||
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
let probeCalls = 0;
|
||||
mgr.probe = async () => {
|
||||
probeCalls++;
|
||||
mgr.status = { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() };
|
||||
mgr._scheduleNext();
|
||||
return mgr.status;
|
||||
};
|
||||
await mgr.probe();
|
||||
ok(probeCalls === 1 && mgr.getStatus().state === 'connected', '首探测 connected');
|
||||
await sleep(RETRY_MS * 3); // 小于稳态间隔、大于重试节奏
|
||||
ok(probeCalls === 1, '已连接态在稳态间隔内不按重试节奏空转');
|
||||
await sleep(INTERVAL_MS + RETRY_MS * 2); // 跨过稳态间隔
|
||||
ok(probeCalls === 2, '跨过稳态间隔后又探测 1 次');
|
||||
await mgr.dispose();
|
||||
}
|
||||
|
||||
console.log('[4] dispose() 停止自动调度');
|
||||
{
|
||||
const mgr = new QmtMcpManager({
|
||||
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
|
||||
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
let bridgeUp = false;
|
||||
let probeCalls = 0;
|
||||
mgr.probe = async () => {
|
||||
probeCalls++;
|
||||
mgr.status = bridgeUp
|
||||
? { state: 'connected', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 9, error: null, latencyMs: 3, checkedAt: Date.now() }
|
||||
: { state: 'error', serverName: 'QMT_Bridge_MCP', url: BRIDGE_URL + '/mcp', mounted: true, toolCount: 0, error: 'ECONNREFUSED', latencyMs: 3, checkedAt: Date.now() };
|
||||
mgr._scheduleNext();
|
||||
return mgr.status;
|
||||
};
|
||||
await mgr.probe();
|
||||
await sleep(RETRY_MS * 2);
|
||||
const before = probeCalls;
|
||||
await mgr.dispose();
|
||||
bridgeUp = true;
|
||||
await sleep(RETRY_MS * 4);
|
||||
ok(probeCalls === before, 'dispose 后无自动探测');
|
||||
}
|
||||
|
||||
console.log('[5] _nextDelay 节奏选择');
|
||||
{
|
||||
const mgr = new QmtMcpManager({
|
||||
ctx: {}, runtime: { settings: mockSettings(BRIDGE_URL), dataSource: {} },
|
||||
logger: quietLogger, intervalMs: INTERVAL_MS, retryMs: RETRY_MS,
|
||||
});
|
||||
mgr.status = { state: 'connected' };
|
||||
ok(mgr._nextDelay() === INTERVAL_MS, 'connected → intervalMs');
|
||||
mgr.status = { state: 'error' };
|
||||
ok(mgr._nextDelay() === RETRY_MS, 'error → retryMs');
|
||||
mgr.status = mgr._disabledStatus();
|
||||
ok(mgr._nextDelay() === RETRY_MS, 'disabled → retryMs(无连接时 probe 早退不调度)');
|
||||
}
|
||||
|
||||
console.log('----');
|
||||
console.log(passed + ' passed, ' + failed + ' failed');
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -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