From 3dfa39b8bfdc8a8a9a50a567680b3181fe74c05b Mon Sep 17 00:00:00 2001 From: AdamGao Date: Thu, 10 Sep 2026 14:04:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A319-21=E6=94=B6=E5=B0=BE:=20?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E7=81=AF=E8=87=AA=E9=80=82=E5=BA=94=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=EF=BC=88R-022/R-023=EF=BC=89+=20=E5=88=97=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=EF=BC=88R-024?= =?UTF-8?q?=EF=BC=89+=20Tab=E8=AE=BE=E7=BD=AE=E9=97=B4=E9=9A=99=E7=BA=BF?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=88R-025=EF=BC=89+=20=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E6=B1=A0=E5=BD=92=E6=A1=A3=EF=BC=88R-014/15/16/17/19=20?= =?UTF-8?q?=E2=86=92=20=E5=B7=B2=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-计划/计划-策略会话数据依据.md | 4 +- docs/04-迭代记录/17-策略会话/UI交互设计.md | 4 +- docs/04-迭代记录/17-策略会话/产品逻辑设计.md | 46 +++++- docs/04-迭代记录/17-策略会话/迭代目标.md | 4 +- .../19-指示灯状态自适应探测/技术实现方案.md | 45 ++++++ .../19-指示灯状态自适应探测/迭代复盘.md | 45 ++++++ .../19-指示灯状态自适应探测/迭代目标.md | 32 ++++ .../19-指示灯状态自适应探测/验收标准.md | 29 ++++ .../20-策略列设置拖拽排序/技术实现方案.md | 88 +++++++++++ .../20-策略列设置拖拽排序/迭代复盘.md | 47 ++++++ .../20-策略列设置拖拽排序/迭代目标.md | 30 ++++ .../20-策略列设置拖拽排序/验收标准.md | 32 ++++ .../21-Tab设置排序间隙高亮统一/技术实现方案.md | 84 ++++++++++ .../21-Tab设置排序间隙高亮统一/迭代目标.md | 26 +++ .../21-Tab设置排序间隙高亮统一/验收标准.md | 27 ++++ docs/05-需求池/R-021.md | 60 +++++++ docs/05-需求池/R-022.md | 37 +++++ docs/05-需求池/R-023.md | 36 +++++ docs/05-需求池/R-025.md | 40 +++++ docs/05-需求池/{ => 已完成}/R-014.md | 8 +- docs/05-需求池/{ => 已完成}/R-015.md | 8 +- docs/05-需求池/{ => 已完成}/R-016.md | 8 +- docs/05-需求池/{ => 已完成}/R-017.md | 8 +- docs/05-需求池/{ => 已完成}/R-019.md | 7 +- docs/05-需求池/已完成/R-024.md | 46 ++++++ scripts/test-health-monitor.mjs | 126 +++++++++++++++ scripts/test-mcp-status.mjs | 149 ++++++++++++++++++ src/data-source/QmtHealthMonitor.js | 79 +++++++--- src/mcp/QmtMcpManager.js | 133 +++++++++++----- 29 files changed, 1216 insertions(+), 72 deletions(-) create mode 100644 docs/04-迭代记录/19-指示灯状态自适应探测/技术实现方案.md create mode 100644 docs/04-迭代记录/19-指示灯状态自适应探测/迭代复盘.md create mode 100644 docs/04-迭代记录/19-指示灯状态自适应探测/迭代目标.md create mode 100644 docs/04-迭代记录/19-指示灯状态自适应探测/验收标准.md create mode 100644 docs/04-迭代记录/20-策略列设置拖拽排序/技术实现方案.md create mode 100644 docs/04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md create mode 100644 docs/04-迭代记录/20-策略列设置拖拽排序/迭代目标.md create mode 100644 docs/04-迭代记录/20-策略列设置拖拽排序/验收标准.md create mode 100644 docs/04-迭代记录/21-Tab设置排序间隙高亮统一/技术实现方案.md create mode 100644 docs/04-迭代记录/21-Tab设置排序间隙高亮统一/迭代目标.md create mode 100644 docs/04-迭代记录/21-Tab设置排序间隙高亮统一/验收标准.md create mode 100644 docs/05-需求池/R-021.md create mode 100644 docs/05-需求池/R-022.md create mode 100644 docs/05-需求池/R-023.md create mode 100644 docs/05-需求池/R-025.md rename docs/05-需求池/{ => 已完成}/R-014.md (85%) rename docs/05-需求池/{ => 已完成}/R-015.md (85%) rename docs/05-需求池/{ => 已完成}/R-016.md (93%) rename docs/05-需求池/{ => 已完成}/R-017.md (83%) rename docs/05-需求池/{ => 已完成}/R-019.md (92%) create mode 100644 docs/05-需求池/已完成/R-024.md create mode 100644 scripts/test-health-monitor.mjs create mode 100644 scripts/test-mcp-status.mjs diff --git a/docs/02-计划/计划-策略会话数据依据.md b/docs/02-计划/计划-策略会话数据依据.md index d6687f3..3a4390e 100644 --- a/docs/02-计划/计划-策略会话数据依据.md +++ b/docs/02-计划/计划-策略会话数据依据.md @@ -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):不用宿主工作区;不开放账本写;不做按需取数工具;复盘产物不落盘;本期不做通用视图(全部持仓/交易记录)作依据。 \ No newline at end of file diff --git a/docs/04-迭代记录/17-策略会话/UI交互设计.md b/docs/04-迭代记录/17-策略会话/UI交互设计.md index ce0b678..4f4bf20 100644 --- a/docs/04-迭代记录/17-策略会话/UI交互设计.md +++ b/docs/04-迭代记录/17-策略会话/UI交互设计.md @@ -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. 交互总览(一句话) @@ -73,4 +73,4 @@ | D-1 | 注入条目呈现形态 | 折叠「数据依据」卡片,点开全文(见 §4.2) | | D-2 | 交易摘要聚合粒度 | 按持仓聚合 + 已清仓标注;如需近期逐笔再加近 N 笔 | | D-3 | 依据跨刷新持久化 | 会话级不持久化(重开重选) | -| D-4 | chip 位置 | 对话视图输入框上方 accessory 行左侧(仿 workspace chip 位置);备选=会话头部与 QMT chip 并排 | +| D-4 | chip 位置 | 对话视图输入框上方 accessory 行左侧(仿 workspace chip 位置);备选=会话头部与 QMT chip 并排 | \ No newline at end of file diff --git a/docs/04-迭代记录/17-策略会话/产品逻辑设计.md b/docs/04-迭代记录/17-策略会话/产品逻辑设计.md index b170607..8d4b097 100644 --- a/docs/04-迭代记录/17-策略会话/产品逻辑设计.md +++ b/docs/04-迭代记录/17-策略会话/产品逻辑设计.md @@ -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 建议 | @@ -88,4 +128,4 @@ | D-1 | 注入条目的呈现形态 | 对话流顶部「数据依据」条目:默认折叠为一行(图标+策略+快照时间+行数摘要),点开看全文;不伪装成用户消息 | | D-2 | 交易摘要聚合粒度 | 按持仓聚合(每持仓一段汇总 + 已清仓标注),不逐笔铺开(避免注入过长);如老师要"近期逐笔"可加近 N 笔小表 | | D-3 | 依据选择是否跨刷新持久化 | 本期**会话级不持久化**(刷新需重选;历史注入条目仍在消息流),与 R-016 会话级偏好一致、不加存储;复盘管理未来再考虑 | -| D-4 | 选择控件挂载位置 | 对话视图输入框上方 accessory 行左侧(仿 DSH workspace chip 位置),仅聊天视图可见;替代方案=会话头部(与 QMT chip 并排)待老师定 | +| D-4 | 选择控件挂载位置 | 对话视图输入框上方 accessory 行左侧(仿 DSH workspace chip 位置),仅聊天视图可见;替代方案=会话头部(与 QMT chip 并排)待老师定 | \ No newline at end of file diff --git a/docs/04-迭代记录/17-策略会话/迭代目标.md b/docs/04-迭代记录/17-策略会话/迭代目标.md index fd3ddb6..7da3f80 100644 --- a/docs/04-迭代记录/17-策略会话/迭代目标.md +++ b/docs/04-迭代记录/17-策略会话/迭代目标.md @@ -6,7 +6,7 @@ ## 目标描述 -- 老师原始诉求「新需求,让 AI 会话可以以策略为 Workspace」经四轮讨论定稿(R-019,2026-09-08): +- 老师原始诉求「新需求,让 AI 会话可以以策略为 Workspace」经四轮讨论定稿(R-019,2026-09-08;同日老师指令 R-019 并入 R-021 合并为单一需求): - **改造现有 DSH 对话窗口**,不引入宿主工作区/目录; - 会话的数据 = ①开始前/过程中注入的策略当前数据摘要 + ②会话过程本身产生的对话;复盘管理(可能带 DB 沉淀)为神之一手未来模块,本期不做; - 注入口径:当前持仓明细 + 该策略全历史关联交易聚合摘要(含已清仓); @@ -36,4 +36,4 @@ ## 对老师配合的请求 1. 拍板 D1-D4(产品/UI 设计中的 AI 建议项,见设计文档末); -2. 实现验收需真实会话环境人工目视。 +2. 实现验收需真实会话环境人工目视。 \ No newline at end of file diff --git a/docs/04-迭代记录/19-指示灯状态自适应探测/技术实现方案.md b/docs/04-迭代记录/19-指示灯状态自适应探测/技术实现方案.md new file mode 100644 index 0000000..956a834 --- /dev/null +++ b/docs/04-迭代记录/19-指示灯状态自适应探测/技术实现方案.md @@ -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 再产物,部署用) diff --git a/docs/04-迭代记录/19-指示灯状态自适应探测/迭代复盘.md b/docs/04-迭代记录/19-指示灯状态自适应探测/迭代复盘.md new file mode 100644 index 0000000..beb8fdf --- /dev/null +++ b/docs/04-迭代记录/19-指示灯状态自适应探测/迭代复盘.md @@ -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 教训)。 diff --git a/docs/04-迭代记录/19-指示灯状态自适应探测/迭代目标.md b/docs/04-迭代记录/19-指示灯状态自适应探测/迭代目标.md new file mode 100644 index 0000000..91c35eb --- /dev/null +++ b/docs/04-迭代记录/19-指示灯状态自适应探测/迭代目标.md @@ -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」仍即时探测; + 插件重载后灯按缓存秒回并正常。 diff --git a/docs/04-迭代记录/19-指示灯状态自适应探测/验收标准.md b/docs/04-迭代记录/19-指示灯状态自适应探测/验收标准.md new file mode 100644 index 0000000..d8c1f05 --- /dev/null +++ b/docs/04-迭代记录/19-指示灯状态自适应探测/验收标准.md @@ -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 定稿并归档(老师确认)。 diff --git a/docs/04-迭代记录/20-策略列设置拖拽排序/技术实现方案.md b/docs/04-迭代记录/20-策略列设置拖拽排序/技术实现方案.md new file mode 100644 index 0000000..509ff65 --- /dev/null +++ b/docs/04-迭代记录/20-策略列设置拖拽排序/技术实现方案.md @@ -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 +
{ 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' }}> + + + + ... +
+``` + +### 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 清理防拖出弹层残留。 +- 边界维持:固定列不参与、服务端零改动、↑↓ 兜底保留、立即持久化不变。 diff --git a/docs/04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md b/docs/04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md new file mode 100644 index 0000000..e235d3f --- /dev/null +++ b/docs/04-迭代记录/20-策略列设置拖拽排序/迭代复盘.md @@ -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)}`),改为条件渲染 `
`。 + +### 验证 + +- 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 项静默失败,直到本迭代回归才暴露)。 \ No newline at end of file diff --git a/docs/04-迭代记录/20-策略列设置拖拽排序/迭代目标.md b/docs/04-迭代记录/20-策略列设置拖拽排序/迭代目标.md new file mode 100644 index 0000000..2f83abd --- /dev/null +++ b/docs/04-迭代记录/20-策略列设置拖拽排序/迭代目标.md @@ -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 推荐默认(立即持久化 + ↑↓ 保留兜底)。 + +## 对老师(项目主理人)的配合需求 + +- 无(纯前端交互优化,服务端零改动,验收走页面人工确认)。 \ No newline at end of file diff --git a/docs/04-迭代记录/20-策略列设置拖拽排序/验收标准.md b/docs/04-迭代记录/20-策略列设置拖拽排序/验收标准.md new file mode 100644 index 0000000..00cb0c3 --- /dev/null +++ b/docs/04-迭代记录/20-策略列设置拖拽排序/验收标准.md @@ -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 归档至 已完成/ 并更新索引实现状态。 \ No newline at end of file diff --git a/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/技术实现方案.md b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/技术实现方案.md new file mode 100644 index 0000000..87f5887 --- /dev/null +++ b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/技术实现方案.md @@ -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 + { 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' }} +> + {gapLine(i, 'td1')} ≡ + ... + +``` + +间隙线样式(与 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 即存、 + 拖回原位不重复写库、刷新页面后顺序保持。 diff --git a/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/迭代目标.md b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/迭代目标.md new file mode 100644 index 0000000..8a8b7db --- /dev/null +++ b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/迭代目标.md @@ -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 实施。 + +## 对老师(项目主理人)的配合需求 + +- 无(纯前端交互统一,服务端零改动,验收走页面人工确认)。 \ No newline at end of file diff --git a/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/验收标准.md b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/验收标准.md new file mode 100644 index 0000000..4d8ec36 --- /dev/null +++ b/docs/04-迭代记录/21-Tab设置排序间隙高亮统一/验收标准.md @@ -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 归档至 已完成/ 并更新索引实现状态。 diff --git a/docs/05-需求池/R-021.md b/docs/05-需求池/R-021.md new file mode 100644 index 0000000..8f0f689 --- /dev/null +++ b/docs/05-需求池/R-021.md @@ -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)** diff --git a/docs/05-需求池/R-022.md b/docs/05-需求池/R-022.md new file mode 100644 index 0000000..62cab85 --- /dev/null +++ b/docs/05-需求池/R-022.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 外,异常会丢缓存导致灯灰且不重试 | diff --git a/docs/05-需求池/R-023.md b/docs/05-需求池/R-023.md new file mode 100644 index 0000000..214bb4e --- /dev/null +++ b/docs/05-需求池/R-023.md @@ -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) | 定时器与手动/热切换探测可能撞车 | diff --git a/docs/05-需求池/R-025.md b/docs/05-需求池/R-025.md new file mode 100644 index 0000000..f7cece5 --- /dev/null +++ b/docs/05-需求池/R-025.md @@ -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 期望过期)不在本需求范围。 \ No newline at end of file diff --git a/docs/05-需求池/R-014.md b/docs/05-需求池/已完成/R-014.md similarity index 85% rename from docs/05-需求池/R-014.md rename to docs/05-需求池/已完成/R-014.md index 592c3b4..6989d5a 100644 --- a/docs/05-需求池/R-014.md +++ b/docs/05-需求池/已完成/R-014.md @@ -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`),带来两个问题: @@ -29,4 +35,4 @@ ## 验收 -见 `docs/04-迭代记录/12-持仓内存快照/验收标准.md`。 +见 `docs/04-迭代记录/12-持仓内存快照/验收标准.md`。 \ No newline at end of file diff --git a/docs/05-需求池/R-015.md b/docs/05-需求池/已完成/R-015.md similarity index 85% rename from docs/05-需求池/R-015.md rename to docs/05-需求池/已完成/R-015.md index 5710b2a..204b683 100644 --- a/docs/05-需求池/R-015.md +++ b/docs/05-需求池/已完成/R-015.md @@ -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)。本轮把**盘口(市场行情)数据**收敛为同一模式: @@ -25,4 +31,4 @@ ## 验收 -见 `docs/04-迭代记录/13-盘口内存快照/验收标准.md`。 +见 `docs/04-迭代记录/13-盘口内存快照/验收标准.md`。 \ No newline at end of file diff --git a/docs/05-需求池/R-016.md b/docs/05-需求池/已完成/R-016.md similarity index 93% rename from docs/05-需求池/R-016.md rename to docs/05-需求池/已完成/R-016.md index 8aaae32..7d6d56b 100644 --- a/docs/05-需求池/R-016.md +++ b/docs/05-需求池/已完成/R-016.md @@ -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 旁边**添加以下功能: @@ -60,4 +66,4 @@ ## 验收 -见 `docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`。 +见 `docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`。 \ No newline at end of file diff --git a/docs/05-需求池/R-017.md b/docs/05-需求池/已完成/R-017.md similarity index 83% rename from docs/05-需求池/R-017.md rename to docs/05-需求池/已完成/R-017.md index ffca701..4b36429 100644 --- a/docs/05-需求池/R-017.md +++ b/docs/05-需求池/已完成/R-017.md @@ -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 区分展示。 @@ -27,4 +33,4 @@ ## 验收 -见 `docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`。 +见 `docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`。 \ No newline at end of file diff --git a/docs/05-需求池/R-019.md b/docs/05-需求池/已完成/R-019.md similarity index 92% rename from docs/05-需求池/R-019.md rename to docs/05-需求池/已完成/R-019.md index 3ef2112..b096f62 100644 --- a/docs/05-需求池/R-019.md +++ b/docs/05-需求池/已完成/R-019.md @@ -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。** @@ -71,4 +76,4 @@ ## 验收 -**(计划/迭代立项后写入迭代验收标准.md)** +**(计划/迭代立项后写入迭代验收标准.md)** \ No newline at end of file diff --git a/docs/05-需求池/已完成/R-024.md b/docs/05-需求池/已完成/R-024.md new file mode 100644 index 0000000..f1d3059 --- /dev/null +++ b/docs/05-需求池/已完成/R-024.md @@ -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) | \ No newline at end of file diff --git a/scripts/test-health-monitor.mjs b/scripts/test-health-monitor.mjs new file mode 100644 index 0000000..186e76c --- /dev/null +++ b/scripts/test-health-monitor.mjs @@ -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); diff --git a/scripts/test-mcp-status.mjs b/scripts/test-mcp-status.mjs new file mode 100644 index 0000000..195406b --- /dev/null +++ b/scripts/test-mcp-status.mjs @@ -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); diff --git a/src/data-source/QmtHealthMonitor.js b/src/data-source/QmtHealthMonitor.js index 19c44ce..58048fb 100644 --- a/src/data-source/QmtHealthMonitor.js +++ b/src/data-source/QmtHealthMonitor.js @@ -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(); } } } diff --git a/src/mcp/QmtMcpManager.js b/src/mcp/QmtMcpManager.js index 42d0bc4..bacf6b7 100644 --- a/src/mcp/QmtMcpManager.js +++ b/src/mcp/QmtMcpManager.js @@ -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|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(); } }