From 1bf85a955b4a25590890f095ce0f5a826f4b468d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 15:38:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E9=93=BE=E8=B7=AF=E5=9B=BE?= =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E5=BF=AB=E7=85=A7=EF=BC=88v0.71?= =?UTF-8?q?=EF=BC=89+=20=E9=9D=A2=E6=9D=BF=E6=9D=A5=E6=BA=90=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=9D=A1=20=E2=80=94=E2=80=94=20=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E7=BB=93=E6=9D=9F=E5=90=8E=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=80=E7=89=87=E7=A9=BA=E7=99=BD=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有) 铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空 - 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形) 无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布 - loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂 - 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」) - 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做) - ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的 --- Docs/api-client.md | 62 +++- cmd/cyrene/wire.go | 6 + cmd/cyrene/wire_frontend.go | 4 + cmd/cyrene/wire_runtime.go | 42 ++- cmd/cyrene/wire_runtime_test.go | 40 +++ frontend/web/src/admin/modules/Agents.tsx | 49 ++- frontend/web/src/admin/modules/Backthink.tsx | 67 ++++- frontend/web/src/admin/modules/Flow.tsx | 40 ++- .../admin/modules/flow/FlowSnapshotHint.tsx | 49 +++ .../modules/flow/flowSnapshot.render.test.ts | 161 ++++++++++ .../modules/flow/useFlowSnapshot.test.ts | 189 ++++++++++++ .../src/admin/modules/flow/useFlowSnapshot.ts | 75 +++++ frontend/web/src/lib/api.ts | 2 + frontend/web/src/lib/api/adminSystem.ts | 27 ++ internal/runtime/loop/flow.go | 34 ++- internal/runtime/loop/flow_snapshot_test.go | 179 +++++++++++ internal/runtime/trace/graph.go | 48 +++ internal/services/gateway/admin_flow.go | 70 +++++ .../services/gateway/admin_flow_graph_test.go | 283 ++++++++++++++++++ internal/services/gateway/contracts.go | 21 ++ internal/services/gateway/deps.go | 6 + internal/services/gateway/routes.go | 5 + internal/services/gateway/server.go | 4 + 23 files changed, 1427 insertions(+), 36 deletions(-) create mode 100644 cmd/cyrene/wire_runtime_test.go create mode 100644 frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx create mode 100644 frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts create mode 100644 frontend/web/src/admin/modules/flow/useFlowSnapshot.test.ts create mode 100644 frontend/web/src/admin/modules/flow/useFlowSnapshot.ts create mode 100644 internal/runtime/loop/flow_snapshot_test.go create mode 100644 internal/services/gateway/admin_flow.go create mode 100644 internal/services/gateway/admin_flow_graph_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 8b2d538..939f086 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -1,6 +1,6 @@ # Cyrene v2 客户端 API 契约 -> **契约版本:`v0.70`**(上一版 `v0.69`,其条目见 §6;`v0.68` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 +> **契约版本:`v0.71`**(上一版 `v0.70`,其条目见 §6;`v0.69` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 @@ -10,6 +10,7 @@ > **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。 > **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。 > **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。 +> **v0.71 是链路图补版(已实现,纯增量)**:新增 1 条管理只读端点 **`GET /api/v1/admin/flow/graph`**,把 `ws.graph_update` 的**最近一轮快照**从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ `/#/admin/flow` 与后台思考行的『查看完整链路导图』永远空着」—— `graph_update` **只走实时帧**,前端图桶(`flowGraphs`)**不持久化**,此前**没有任何**补拉通路。**无破坏性变更**(新端点;`graph_update` 帧形状、角色门、发布会话范围**一字未改**)。 --- @@ -122,10 +123,12 @@ | 档位 | 中间件 | 条数 | 说明 | |---|:--:|:--:|---| | **只读监控档** | `viewerAuth`(owner/admin/viewer) | **12** | 非敏感投影:无凭据、无他人账号数据、无消息正文 | -| **owner/admin 读档** | `writeAuth`(owner/admin) | **12** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68) | +| **owner/admin 读档** | `writeAuth`(owner/admin) | **13** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71) | | **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | -**管理路由总数 = 12 + 12 + 40 = 64 条。** +**管理路由总数 = 12 + 13 + 40 = 65 条。** + +> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 13。逐条以代码为准。 > ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。 @@ -146,7 +149,7 @@ | A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) | | A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) | -### 2.2 owner/admin 读(12 条,`writeAuth`) +### 2.2 owner/admin 读(13 条,`writeAuth`) | # | 方法 | 路径 | 要点 | |---|---|---|---| @@ -162,6 +165,7 @@ | B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 | | B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) | | B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 | +| B13 | GET | `/api/v1/admin/flow/graph` | **最近一轮链路图快照(v0.71 新增,只读、不审计)**。`?sessionId=`(省略 = 主会话 `web`);`{data:{sessionId,graph}}`,`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,该会话还没有图时**如实为 `null`**(**绝不画假图**)。**只回最近一轮**(更早轮次的历史回放未实现,见 §7.4-18);未接线图源 → `500 flow_unavailable`。语义见 §4.11 | > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 @@ -381,7 +385,7 @@ | `device_update` | — | 桥接自 `iot.device_update` | | `automation_result` | — | 桥接自 `automation.result` | | `reminder` | — | 桥接自 `reminder.trigger` | -| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**无 `turnId`** ⇒ 未按路分桶) | +| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**无 `turnId`** ⇒ 未按路分桶)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照** | | `capacity_awaken` | sessionId | `{fromState,toState,tool}` | | `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 | | `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId}`(已消毒,绝不含凭据/媒体字节) | @@ -719,6 +723,34 @@ - **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。 - 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。 +### 4.11 v0.71 新增:最近一轮链路图快照(`GET /api/v1/admin/flow/graph`) + +**为什么有这条**:`graph_update`(§3.4.5)是**纯实时帧** —— 后端在每个 span 开始/结束时广播一次,**不重放**;前端把图存在**内存**的会话图桶里(`flowGraphs`,**不持久化**)。于是「**轮次结束后才打开页面**」或「**刷新过页面**」看到的永远是空态(`/#/admin/flow` 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。本端点补上唯一缺的那一环:**打开页面时按会话只读拉一次最近一轮的快照**。 + +#### (a) 请求与响应 + +| 项 | 值 | +|---|---| +| 方法 / 路径 | `GET /api/v1/admin/flow/graph` | +| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 `design/19 §3`「flow 可视化仅 owner/admin」一致 | +| 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:`(次级会话) | +| 响应体 | `{data:{sessionId:"<解析后的会话 id>", graph: |null}}` | +| `graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) | +| 归档 | **不审计**(只读查询,与同档的 `/admin/llm`、`/admin/config-facts` 一致) | +| 失败 | 图源未接线 ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这一轮没有图」**);该会话**确实**还没有图 ⇒ `200` + `graph:null` | + +#### (b) 语义硬约束(客户端必须照这个读) + +1. **它是「最近一轮」而不是「你要的那一轮」。** 后端只保留每个会话**最新**的一份图(`loop.FlowPublisher` 每会话一张,`StartRound` 重置)。因此后台思考行上点开的图**可能不是该行那一轮**——**面板文案必须说明这一点**(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),不得让用户以为「每行都有自己那轮的图」。**多轮历史回放**仍是未实现项(`design/19 §4` 的 P3,见 §7.4-18)。 +2. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图。 +3. **`graph:null` 是诚实的空。** 会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。 +4. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。 +5. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点只补「已经跑完、帧已经错过」的那一轮。 + +#### (c) 与 design/19 的关系 + +`design/19 §4` 把「多轮历史回放」列为 **P3**;本端点落实的是 P3 里**最小的一步**(每会话最近一轮的只读快照),**不改** design/19 §1 的发布会话范围(`web` / `web:` / `sess-backthink`,裁决 01.4.5)、**不改** `graph_update` 两路互顶的既有裁决(见 §7.4-19)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。 + --- ## 5. env 与模型配置(与契约相关) @@ -782,7 +814,20 @@ > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** -### v0.70(当前契约版本) +### v0.71(当前契约版本) + +**链路图补版 —— 新增只读端点 `GET /api/v1/admin/flow/graph`,让「轮次结束后才打开的页面」也能看到最近一轮的链路图**(依据主人 2026-10-05 第二次报「`/#/admin/flow` 停在暂无流程图数据 + 后台思考行提示该会话还没有 graph_update 快照」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」里的最小一步)。 + +- **纯增量**: + ① **新 admin 只读端点 `GET /api/v1/admin/flow/graph?sessionId=`**(`owner`/`admin`,`?sessionId=` 省略 = 主会话 `web`):返回 `{data:{sessionId,graph}}`,`graph` 与 `graph_update` 帧的 `data` **逐字同形**;该会话还没有图 ⇒ **如实 `null`**(绝不画假图);图源未接线 ⇒ `500 flow_unavailable`(**不假装「这一轮没有图」**)—— **§2.2 B13 / §4.11**。权限矩阵随之更新:`writeAuth` 12 → **13**,总数 64 → **65**。 + ② §3.4.5 `graph_update` 行补一句「**纯实时、不重放**」并指向本端点。 + ③ §7.4 新增两条诚实边界:**「最近一轮」≠「你要的那一轮」**(后台思考行上的图可能不是该行那轮;面板必须在图上说明)、**图源是进程内内存**(后端重启后如实回 `null`)。 +- **修的是什么(根因)**:`graph_update` **只走实时帧**,前端图桶 `flowGraphs` **不持久化**(`store/persistOptions.ts` 的 `partialize` 不含它),且此前**没有任何 REST/WS 补拉通路** ⇒ 页面在轮次结束后打开、或刷新过页面,就永远空着。本端点补上唯一缺的那一环。 +- **行为变更**:无(既有端点/帧一字未动)。 +- **破坏性**:无(新端点;`graph_update` 帧形状、`frameReachesAll` 角色门、发布会话范围(裁决 01.4.5)**全部未改**)。 +- ⚠️ **诚实边界**:只回**每会话最近一轮**;**多轮历史回放**(按 turn/时间点取任意一轮)**仍未实现**(`design/19 §4` P3)—— 见 §7.4-18。**`graph_update` 两路互顶**的既有裁决**本版未动**(§7.4-19)。 + +### v0.70(上一版) **后台思考行的紧急度精确化 —— `urgency` 默认即精确档位,另加 `urgencyLowerBound` 标记下限**(依据主人 2026-10-05 裁定「要**精确**」;P4 只能给下限,本版把精确值送到两面:管理 REST 与实时帧)。 @@ -986,6 +1031,9 @@ - **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。 - **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。 +18. **(v0.71 登记)「最近一轮」≠「你要的那一轮」**:`GET /api/v1/admin/flow/graph`(§4.11)只回该会话**最新**的一张图(`loop.FlowPublisher` 每会话只留一份)。**后台思考行**上点开的图因此可能不是该行那一轮 ⇒ 面板**必须在图上说明**(当前 `frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),客户端**不得**把它渲染成「这一行的链路」。**多轮历史回放**(按 turn/时间点取任意一轮)**未实现**,属 `design/19 §4` 的 **P3**。 +19. **(v0.71 登记)图源是**进程内内存**,进程重启即清空**:`loop.FlowPublisher` 的每会话最近一图**不落库**(design/19 §1 的实时口径)⇒ 后端重启后、该会话还没跑新轮次时,端点如实回 `graph:null`。这是**有意**的诚实空态,不是丢数据(图从来不是持久资产)。同时**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。 + --- -*本文件由「以代码为真源」维护(当前 `v0.70`;`v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.70` 同步了 `internal/services/gateway/**`、`frontend/web/src/**` 与本文件;`v0.69` 同步了 `internal/services/gateway/**` / `internal/config/**` 与本文件。* +*本文件由「以代码为真源」维护(当前 `v0.71`;`v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件;`v0.70` 同步了 `internal/services/gateway/**`、`frontend/web/src/**` 与本文件;`v0.69` 同步了 `internal/services/gateway/**` / `internal/config/**` 与本文件。* diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index d97229a..1287914 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -124,6 +124,12 @@ type app struct { // design/19 §2)。持有它是为了让 wireFrontend 能往已在运行的 tracer 上注册 // 网关出口。 traceWriter *traceFanout + // flow 是 loop 的实时链路图发布器(design/19 §1)。持有它是因为网关还要一个 + // **只读**出口:`GET /api/v1/admin/flow/graph` 回「最近一轮」的快照,补上 + // 「轮次结束后才打开的页面」看不到图的那个洞(契约 v0.71)。组合根把**同一个** + // 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的 + // 就是实时发布的那张图,不会出现两份状态。 + flow *loop.FlowPublisher } // newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线 diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index edb046d..6835bdd 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -85,6 +85,10 @@ func (a *app) wireFrontend() error { DataDir: resolveDataDir(a.cfg), Database: a.db, Vector: a.vectorState(), + // Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面 + // loop/orchestration 用的**同一个** *loop.FlowPublisher,因此 + // GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。 + Flow: a.flow, // Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的 // 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500 // usage_unavailable,不回假汇总)。 diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 62e3899..c45b38d 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -218,12 +218,13 @@ func (a *app) wireRuntime() error { // 发布会话范围(裁决 01.4.5)的既有裁决,本批未改。nil flow 保持 loop 原行为。 flowPub := loop.NewFlowPublisher( func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) }, - func(sessionID string) bool { - return sessionID == history.MainSessionID || - strings.HasPrefix(sessionID, history.MainSessionID+":") || - sessionID == backthink.DefaultSessionID - }, + flowSessionAllowed, ) + // 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口 + // (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面 + // (wireFrontend)必须**是同一个对象**,否则面板补拉到的图与实时收到的图 + // 会是两份状态。 + a.flow = flowPub checkpoint, perr := persist.New(context.Background(), a.db, a.log) if perr != nil { return fmt.Errorf("checkpoint store: %w", perr) @@ -417,3 +418,34 @@ func (a *app) defaultInstructions() string { } return fallbackInstruction } + +// flowSessionAllowed 是实时链路图的**发布会话白名单**(design/19 §1 裁决 01.4.5 +// + 裁决 16.4.2),也是后端唯一一条「哪些会话能产出链路图」的判据 —— +// `FlowPublisher.LastGraph`(契约 v0.71 的只读快照出口)读的就是按它建过图的那些 +// 会话,因此白名单外的会话**既发不出实时帧、也查不到快照**。 +// +// 三档(本项目当前实现口径,逐档都有理由): +// - `web`:Web 主会话(`history.MainSessionID`)—— 主人对话的正身; +// - `web:`:Web 次级会话(IM 主人私聊镜像也落这个族); +// - `sess-backthink`:自主后台思考的固定内部会话(design/20 §2)。 +// +// **隔离的渠道会话(群聊 / 陌生人,`sess-xxx`)不在此列** —— 那是防敏感信息 +// 泄向管理面板的既有裁决,不是省事。子代理的 span 由 `loop.WithFlowBranch` +// 并进**派生它的那个会话**(design/19 §2),因此不需要单独一档。 +// +// 抽成具名函数只为可测:白名单是裁决本身,删掉任何一档都必须有测试打红 +// (见 wire_runtime_test.go 的 TestFlowSessionAllowed)。 +func flowSessionAllowed(sessionID string) bool { + switch { + case sessionID == "": + return false + case sessionID == history.MainSessionID: + return true + case strings.HasPrefix(sessionID, history.MainSessionID+":"): + return true + case sessionID == backthink.DefaultSessionID: + return true + default: + return false + } +} diff --git a/cmd/cyrene/wire_runtime_test.go b/cmd/cyrene/wire_runtime_test.go new file mode 100644 index 0000000..a51d4cb --- /dev/null +++ b/cmd/cyrene/wire_runtime_test.go @@ -0,0 +1,40 @@ +package main + +import "testing" + +// 链路图发布会话白名单是**裁决本身**(design/19 §1 裁决 01.4.5 + 裁决 16.4.2), +// 不是实现细节:它决定哪些会话能产出实时帧,也决定契约 v0.71 的只读快照出口 +// (`GET /api/v1/admin/flow/graph`)能查到什么。删掉任何一档都必须打红。 +// +// 这条测试同时钉住「隔离的渠道会话绝不进白名单」—— 群聊 / 陌生人会话一旦被 +// 发进管理面板,就是把主人的私聊以外的内容泄漏给面板,属红线。 +func TestFlowSessionAllowed(t *testing.T) { + t.Parallel() + allowed := []string{ + "web", + "web:0f3a0b1c-2d3e-4f50-8a9b-1c2d3e4f5061", + "web:another-uuid", + "sess-backthink", + } + for _, session := range allowed { + if !flowSessionAllowed(session) { + t.Errorf("flowSessionAllowed(%q) = false, want true(该档属 design/19 §1 的发布会话范围)", session) + } + } + + denied := []string{ + "", + "webbish", // 与 "web" 前缀相似但不是主会话族 + "webx:uuid", // 前缀不匹配必须整档拒绝(HasPrefix 只认 "web:") + "sess-im-12345", // IM 隔离会话(群聊) + "sess-stranger-999", // 陌生人私聊 + "sess-memorytidy", // 系统任务会话(design/23,刻意不在白名单) + "sess-backthink-2", // 后缀不同即不是那个固定会话 + "sess-backthink ", // 尾部空格不得被宽容 + } + for _, session := range denied { + if flowSessionAllowed(session) { + t.Errorf("flowSessionAllowed(%q) = true,但它必须被拒(隔离会话/系统任务/非法 id 一律不进白名单)", session) + } + } +} diff --git a/frontend/web/src/admin/modules/Agents.tsx b/frontend/web/src/admin/modules/Agents.tsx index 28ed318..4a1962d 100644 --- a/frontend/web/src/admin/modules/Agents.tsx +++ b/frontend/web/src/admin/modules/Agents.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; import { useAppStore } from "../../store/useAppStore"; -import type { AgentInfo, AgentStatus } from "../../lib/types"; +import type { AgentInfo, AgentStatus, FlowGraph } from "../../lib/types"; import { Notice, PageHeader } from "../components"; import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives"; import { sendUp } from "../../lib/ws"; import { useAuditStore } from "../audit"; import { Icon } from "../../components/Icon"; import { FlowGraphView } from "./FlowGraph"; +import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; +import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot"; +import { MAIN_SESSION_ID } from "../../store/sessionCache"; const statusTone: Record = { idle: { label: "空闲", tone: "neutral" }, @@ -68,6 +71,10 @@ function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode { export function AgentsModule(): React.ReactNode { const agents = useAppStore((s) => s.agents); + const activeSessionId = useAppStore((s) => s.activeSessionId); + // 与 Flow 页同一条补拉(契约 v0.71):本页读的也是「激活会话」的图, + // 刷新过页面后实时帧已经错过,不补拉就永远停在空态。 + const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID); const flowGraph = useAppStore((s) => s.flowGraph); const taskLogs = useAppStore((s) => s.taskLogs); const clearTaskLog = useAppStore((s) => s.clearTaskLog); @@ -105,13 +112,11 @@ export function AgentsModule(): React.ReactNode { setTab("sub")}>子代理 ({subAgents.length}) - {flowGraph ? ( - - - - ) : ( - 暂无流程图数据 —— 连接 /ws/chat 后按 graph_update 事件实时渲染。 - )} + {list.length === 0 ? ( } title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} /> @@ -146,8 +151,34 @@ export function AgentsModule(): React.ReactNode { ); } -function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { +/** 流程图这一块(有图就画、没图就诚实空态)。props 驱动(不读 store), + * 因此可被真实渲染测试直接喂两种输入(见 flowSnapshot.render.test.ts 头注释)。 + * + * 空态文案里必须说明**两条**来源都查过(实时帧 + 最近一轮快照,契约 v0.71): + * 否则用户分不清「本会话没跑过带 span 的轮次」与「图源没接线」。 */ +export function AgentsFlowBlock({ + graph, + sessionId, + source, +}: { + graph: FlowGraph | null; + sessionId: string; + source: FlowGraphSource; +}): React.ReactNode { + if (!graph) { + return ( + 暂无流程图数据 —— 本会话还没有跑过带 span 的轮次(实时帧与「最近一轮快照」都为空)。发起一次会话后此处会显示链路图。 + ); + } return ( + + + + + ); +} + +function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { return ( {open ? ( - graph ? ( - - ) : ( -
- {wsStatus === "connected" - ? "后台思考会话尚未产生链路图 —— 该会话还没有 graph_update 快照(本轮思考未跑 span,或后端未发布)。" - : `实时通道未连接(${wsStatus}),链路图无法更新 —— 这不等于「本轮思考没有链路」。`} -
- ) + ) : null} ); } +/** 「查看完整链路导图」展开的那一块(有图就画、没图就诚实空态)。 + * props 驱动(不读 store),因此可被真实渲染测试直接喂两种输入。 + * + * 契约 v0.71 §4.11(b)-1:后端每会话只保留**最近一轮**的图 ⇒ 这一行展开的图 + * 可能**不是这一行那一轮**的图;说明条必须讲明,不得让用户以为「每行有自己那轮的图」。 + * 空态文案同理必须说清两条来源(实时帧 + 最近一轮快照)都为空。 */ +export function BackthinkGraphBlock({ + graph, + sessionId, + source, + wsStatus, +}: { + graph: FlowGraph | null; + sessionId: string; + source: FlowGraphSource; + wsStatus: WsStatus; +}): React.ReactNode { + if (!graph) { + return ( +
+ {wsStatus === "connected" + ? "后台思考会话尚未产生链路图 —— 实时帧与「最近一轮快照」(GET /api/v1/admin/flow/graph)都为空,即 sess-backthink 至今没有跑过带 span 的轮次。" + : `实时通道未连接(${wsStatus}),链路图无法更新 —— 这不等于「本轮思考没有链路」。`} +
+ ); + } + return ( + <> + + + + ); +} + export function BackthinkModule(): React.ReactNode { const backthink = useAppStore((s) => s.backthink); const log = useAuditStore((s) => s.log); + // 契约 v0.71:`graph_update` 只走实时帧、从不重放,图桶也不持久化 ⇒ 打开本页时 + // 先只读拉一次 sess-backthink 的**最近一轮**图(否则每一行都只显示「尚未产生链路图」)。 + // 一次拉取在模块级做(不是每行一个 hook)—— 行数多时不会打出 N 个重复请求。 + const graphSource = useFlowSnapshot(BACKTHINK_SESSION_ID); const [filter, setFilter] = useState<"all" | "notify" | "silent">("all"); const [interveneHint, setInterveneHint] = useState(false); // Cold-load persisted history once (design/12 §4); real-time updates come from the @@ -138,7 +181,7 @@ export function BackthinkModule(): React.ReactNode { ) : (
{items.map((b) => ( - + ))}
)} diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index 74fac03..93dc8ab 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -7,14 +7,48 @@ import { Icon } from "../../components/Icon"; import { FlowGraphView } from "./FlowGraph"; import { NodeDetail } from "./flow/NodeDetail"; import { LaneSummary } from "./flow/LaneSummary"; +import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; +import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot"; import { groupFlowLanes } from "./flow/lanes"; import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone"; +import { MAIN_SESSION_ID } from "../../store/sessionCache"; /** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 —— * 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器), - * 节点点开看参数 / 结果 / 实时日志。 */ + * 节点点开看参数 / 结果 / 实时日志。 + * + * 图有**两个来源**(契约 v0.71):本次页面存活期间收到的实时帧,以及打开页面时 + * 只读拉取的「最近一轮快照」(`GET /api/v1/admin/flow/graph`)。后者修的是 + * 「轮次结束后才打开页面 / 刷新过页面 ⇒ 图桶是空的」那个洞 —— 实时帧从不重放。 + * 说明条(FlowSnapshotHint)把当前这一张的来源讲明,不让用户误以为它是实时图。 + * + * 本组件只做 store 接线;有图 / 无图两支的呈现交给 `FlowBody`(props 驱动), + * 使那条判定能被真实渲染测试钉住(见 flowSnapshot.render.test.ts 头注释)。 */ export function FlowModule(): React.ReactNode { + // 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。 + const activeSessionId = useAppStore((s) => s.activeSessionId); + const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID); const graph = useAppStore((s) => s.flowGraph); + return ( + + ); +} + +/** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被 + * 真实渲染测试直接喂两种输入。 */ +export function FlowBody({ + graph, + sessionId, + source, +}: { + graph: FlowGraph | null; + sessionId: string; + source: FlowGraphSource; +}): React.ReactNode { const [selectedId, setSelectedId] = useState(null); const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null; const grouping = graph ? groupFlowLanes(graph) : null; @@ -68,6 +102,10 @@ export function FlowModule(): React.ReactNode { onNodeClick={(n) => setSelectedId(n.id)} /> + {selected ? ( diff --git a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx new file mode 100644 index 0000000..6ea7136 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx @@ -0,0 +1,49 @@ +import { BACKTHINK_SESSION_ID } from "../../../store/sessionCache"; + +/** 会话 id → 人话标签(图上「这是哪个会话的图」必须看得懂)。未知会话 + * 原样显示 id,绝不猜一个更好听的名字。 */ +function sessionLabel(sessionId: string): string { + if (sessionId === BACKTHINK_SESSION_ID) return "后台思考会话"; + if (sessionId === "web") return "Web 主会话"; + return sessionId; +} + +/** 链路图快照的诚实说明条(契约 v0.71 §4.11(b)-1)。 + * + * 为什么必须有它:后端只保留**每个会话最近一轮**的图,而补拉通路的用途正是 + * 「轮次结束后才打开页面」。于是图上画的到底是哪一种来源必须讲清: + * - `snapshot` = 打开页面时**只读拉取**的那一份最近一轮(不是实时帧,也**不一定 + * 是你眼前这一行那一轮** —— 后台思考行尤其如此); + * - `live` = 这次页面存活期间**实时收到**的图(正在跑 / 刚跑完的那一轮)。 + * + * 不写这一条就等于让用户以为「这行思考有自己的图」,那是把推导当事实: + * 多轮历史回放仍未实现(`design/19 §4` P3)。 */ +export function FlowSnapshotHint({ + sessionId, + source, +}: { + sessionId: string; + source: "live" | "snapshot"; +}): React.ReactNode { + const label = sessionLabel(sessionId); + return ( +
+ {source === "snapshot" ? ( + <> + 以上是 {label}( + {sessionId})的 + 最近一轮 + 链路图快照 —— 打开本页时只读拉取(GET /api/v1/admin/flow/graph)。 + 它不一定是你要找的那一轮:后端每会话只保留最近一份图,多轮历史回放尚未实现(design/19 §4 P3)。 + + ) : ( + <> + 以上是 {label}( + {sessionId})在本次页面存活期间 + 实时收到 + 的链路图(正在跑或最近跑完的那一轮)。刷新页面后它由「最近一轮快照」补上。 + + )} +
+ ); +} diff --git a/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts b/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts new file mode 100644 index 0000000..21b98c1 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts @@ -0,0 +1,161 @@ +// 「最近一轮链路图快照」的**真实渲染**测试(契约 v0.71 §4.11)。 +// +// 为什么需要它:主人第二次报的两条都是**看得见**的症状 —— +// ① `/#/admin/flow` 停在「暂无流程图数据」;② 后台思考行说「该会话还没有 graph_update +// 快照」。逻辑测试(useFlowSnapshot.test.ts)钉住「图真的进了桶」,这里钉住 +// 「桶里有图 ⇒ 界面真的把它画出来(而不是照样空态)」,以及**来源必须如实标注** —— +// 后端只保留每会话最近一轮,把快照画成「这一行的图」就是把推导当事实。 +// +// 渲染的对象是三个 **props 驱动**的呈现组件(FlowBody / AgentsFlowBlock / +// BackthinkGraphBlock):本项目没有 jsdom,且 zustand v5 在 SSR 下用 +// `getInitialState()` 作 server snapshot(`node_modules/zustand/esm/react.mjs`), +// store 驱动的组件在测试里只能看到初始 state ⇒ 真实渲染测试必须喂 props。 +// +// 跑法(同 BackthinkUrgencyTags.render.test.ts): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSnapshot.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-flow-snapshot-model.test.cjs \ +// && node /tmp/cyrene-flow-snapshot-model.test.cjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { FlowGraph } from "../../../lib/types"; +import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { AgentsFlowBlock } from "../Agents"; +import { BackthinkGraphBlock } from "../Backthink"; +import { FlowBody } from "../Flow"; +import { FlowSnapshotHint } from "./FlowSnapshotHint"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** 一轮「输入 → 思考 → 输出」的最小图;节点的 label 是断言用的哨兵。 */ +const roundGraph: FlowGraph = { + id: "web-11", + sessionId: MAIN_SESSION_ID, + startedAt: "2026-10-05T15:17:08+08:00", + endedAt: "2026-10-05T15:17:10+08:00", + nodes: [ + { id: "input", label: "哨兵输入", kind: "input", status: "done" }, + { id: "llm-1", label: "哨兵思考", kind: "llm", status: "done" }, + { id: "output", label: "哨兵回复", kind: "output", status: "done" }, + ], + edges: [ + { from: "input", to: "llm-1" }, + { from: "llm-1", to: "output" }, + ], +}; + +// ── ① 空态:确实没有图时是诚实的空态文案(且不画任何节点) ──────────────── +const emptyFlow = renderToStaticMarkup( + createElement(FlowBody, { graph: null, sessionId: MAIN_SESSION_ID, source: null }) +); +ok(has(emptyFlow, "暂无流程"), "没有图时 Flow 页显示空态"); +ok(!has(emptyFlow, "哨兵"), "空态绝不能画出任何节点(不画假图)"); + +const emptyAgents = renderToStaticMarkup( + createElement(AgentsFlowBlock, { graph: null, sessionId: MAIN_SESSION_ID, source: null }) +); +ok(has(emptyAgents, "暂无流程图数据"), "没有图时 Agents 页显示空态"); +ok( + has(emptyAgents, "最近一轮快照"), + "Agents 页的空态必须说明它也查过「最近一轮快照」(否则用户分不清是没跑过还是没接线)" +); + +const emptyBackthink = renderToStaticMarkup( + createElement(BackthinkGraphBlock, { + graph: null, + sessionId: BACKTHINK_SESSION_ID, + source: null, + wsStatus: "connected", + }) +); +ok( + has(emptyBackthink, "最近一轮快照"), + "后台思考行的空态必须说明它也查过「最近一轮快照」(主人报的就是这句)" +); +ok( + has(emptyBackthink, "/api/v1/admin/flow/graph"), + "后台思考行的空态必须说清查的是哪个端点,便于对照" +); + +// ── ② 有快照 ⇒ 三处都真的画出该轮的图(不再是空态) ────────────────────── +const withGraph = renderToStaticMarkup( + createElement(FlowBody, { + graph: roundGraph, + sessionId: MAIN_SESSION_ID, + source: "snapshot", + }) +); +ok(!has(withGraph, "暂无流程"), "桶里有图时 Flow 页不该再显示空态"); +ok(has(withGraph, "哨兵输入"), "Flow 页必须画出 input 节点(主人报的就是这里空着)"); +ok(has(withGraph, "哨兵思考"), "Flow 页必须画出 llm 节点"); +ok(has(withGraph, "哨兵回复"), "Flow 页必须画出 output 节点"); + +const agentsWithGraph = renderToStaticMarkup( + createElement(AgentsFlowBlock, { + graph: roundGraph, + sessionId: MAIN_SESSION_ID, + source: "snapshot", + }) +); +ok(!has(agentsWithGraph, "暂无流程图数据"), "桶里有图时 Agents 页不该再显示空态"); +ok(has(agentsWithGraph, "哨兵输入"), "Agents 页必须画出该轮的图"); + +const btWithGraph = renderToStaticMarkup( + createElement(BackthinkGraphBlock, { + graph: roundGraph, + sessionId: BACKTHINK_SESSION_ID, + source: "snapshot", + wsStatus: "connected", + }) +); +ok(!has(btWithGraph, "尚未产生链路图"), "桶里有图时后台思考行不该再提示没有快照"); +ok(has(btWithGraph, "哨兵输入"), "后台思考行展开后必须画出该轮的图"); + +// ── ③ 来源必须如实标注:快照 ≠ 实时 ────────────────────────────────────── +const snapshotHint = renderToStaticMarkup( + createElement(FlowSnapshotHint, { sessionId: MAIN_SESSION_ID, source: "snapshot" }) +); +ok( + has(snapshotHint, "最近一轮"), + "快照来源必须写明「最近一轮」(后端每会话只留一份,它不一定是你眼前那行那轮)" +); +ok( + has(snapshotHint, "/api/v1/admin/flow/graph"), + "快照来源必须写明它是只读拉取(GET /api/v1/admin/flow/graph)" +); +ok( + has(snapshotHint, "多轮历史回放尚未实现"), + "快照来源必须诚实标注「多轮历史回放尚未实现」,不得让用户以为每行都有自己的图" +); + +const liveHint = renderToStaticMarkup( + createElement(FlowSnapshotHint, { sessionId: MAIN_SESSION_ID, source: "live" }) +); +ok(has(liveHint, "实时"), "实时来源必须标注为实时收到"); +ok( + !has(liveHint, "多轮历史回放尚未实现"), + "实时图不是快照,不该出现快照专属的免责说明" +); + +// 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。 +const btHint = renderToStaticMarkup( + createElement(FlowSnapshotHint, { sessionId: BACKTHINK_SESSION_ID, source: "snapshot" }) +); +ok(has(btHint, "后台思考会话"), "sess-backthink 必须显示为「后台思考会话」而不是裸 id"); +ok(has(btHint, BACKTHINK_SESSION_ID), "会话 id 也必须如实露出,便于对照"); + +console.log(`flow snapshot render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/useFlowSnapshot.test.ts b/frontend/web/src/admin/modules/flow/useFlowSnapshot.test.ts new file mode 100644 index 0000000..be01ef0 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/useFlowSnapshot.test.ts @@ -0,0 +1,189 @@ +// 「最近一轮链路图快照」逻辑测试(契约 v0.71 §4.11)。 +// +// 为什么需要它:主人第二次报 `/#/admin/flow` 停在「暂无流程图数据」、后台思考行说 +// 「该会话还没有 graph_update 快照」。根因是**图只走实时帧**(从不重放)+ 前端图桶 +// 不持久化 ⇒ 轮次结束后打开的页面永远空着。后端补了只读快照端点,前端必须真的**拉** +// 并**写进图桶**,否则洞还在。 +// +// 本项目没有前端测试运行器、也没有 jsdom,因此这是无依赖的纯 node 检查:它驱动 +// **真的** api 客户端(`lib/api` 的 request → fetch)、**真的** store 写路径 +// (`setFlowGraph` → `putGraph`),只把 hook 的两条判定(要不要拉 / 这张图是哪来的) +// 当纯函数直接钉住 —— effect 本身在本项目无法执行。 +// +// 跑法: +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/useFlowSnapshot.test.ts \ +// --bundle --platform=node --format=esm \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-flow-snapshot.test.mjs \ +// && node /tmp/cyrene-flow-snapshot.test.mjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*(用 globalThis 取 fetch)。 + +import { adminFlowGraph } from "../../../lib/api"; +import { useAppStore } from "../../../store/useAppStore"; +import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache"; +import type { FlowGraph } from "../../../lib/types"; +import { + flowGraphSourceOf, + needsFlowSnapshot, +} from "./useFlowSnapshot"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`); +} + +const roundGraph = (id: string, sessionId: string): FlowGraph => ({ + id, + sessionId, + nodes: [ + { id: "input", label: "你好", kind: "input", status: "done" }, + { id: "llm-1", label: "思考", kind: "llm", status: "done" }, + ], + edges: [{ from: "input", to: "llm-1" }], +}); + +/** 记下每个请求的 URL 并回一份假的快照响应。 */ +interface FetchCall { + url: string; + method: string; +} +const calls: FetchCall[] = []; +const originalFetch = globalThis.fetch; +const originalLocalStorage = globalThis.localStorage; + +/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它, + * 因此给一个只读空壳(与 messageSync.test.ts 同一手法)。 */ +interface StorageLike { + getItem: () => string | null; + setItem: () => void; + removeItem: () => void; +} +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +} as unknown as Storage; + +function stubFetch(body: unknown, status = 200): void { + globalThis.fetch = ((url: string, init?: { method?: string }) => { + calls.push({ url: String(url), method: init?.method ?? "GET" }); + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + headers: { get: () => "application/json" }, + json: () => Promise.resolve(body), + }); + }) as unknown as typeof globalThis.fetch; +} + +// ── ① 「轮次结束后才打开页面」的前端半边(主人的原话场景) ──────────────── +// 图桶是空的(刷新/晚开)⇒ 必须补拉;拉回来的图必须真的进桶,且 +// Flow / Agents 页读的兼容字段 `flowGraph` 立刻可用(不再是空态)。 +useAppStore.getState().resetChat(); +useAppStore.setState({ activeSessionId: MAIN_SESSION_ID }); +same( + needsFlowSnapshot(MAIN_SESSION_ID, useAppStore.getState().flowGraphs[MAIN_SESSION_ID]), + true, + "图桶为空时必须补拉(这正是「轮次结束后才打开页面」的形态)" +); + +const snapshot = roundGraph("web-7", MAIN_SESSION_ID); +stubFetch({ data: { sessionId: MAIN_SESSION_ID, graph: snapshot } }); +const resp = await adminFlowGraph(MAIN_SESSION_ID); +same(calls.length, 1, "一次补拉恰好一个请求"); +ok( + calls[0].url.includes("/admin/flow/graph") && calls[0].url.includes("sessionId=web"), + `请求 URL 必须带上会话 id:${calls[0].url}` +); +same(calls[0].method, "GET", "补拉是只读 GET"); +ok(resp.graph !== null, "后端回了图 ⇒ 解析后 graph 非空"); + +// hook 里写的这一步(`setFlowGraph(resp.graph, sessionId)`)在这里原样执行: +useAppStore.getState().setFlowGraph(resp.graph!, MAIN_SESSION_ID); +same( + useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id, + "web-7", + "快照按会话写进图桶" +); +same( + useAppStore.getState().flowGraph?.id, + "web-7", + "激活会话的兼容字段 flowGraph 立刻可用 ⇒ Flow / Agents 页不再显示空态" +); +same( + flowGraphSourceOf(useAppStore.getState().flowGraphs[MAIN_SESSION_ID], resp.graph), + "snapshot", + "这张图是补拉来的 ⇒ 来源必须标 snapshot(面板据实说明「最近一轮」)" +); +same( + needsFlowSnapshot(MAIN_SESSION_ID, useAppStore.getState().flowGraphs[MAIN_SESSION_ID]), + false, + "已经有图了 ⇒ 不再补拉(绝不覆盖实时图 / 不重复请求)" +); + +// ── ② 实时帧到达后来源翻成 live ────────────────────────────────────────── +useAppStore.getState().setFlowGraph(roundGraph("web-8", MAIN_SESSION_ID), MAIN_SESSION_ID); +same( + flowGraphSourceOf(useAppStore.getState().flowGraphs[MAIN_SESSION_ID], resp.graph), + "live", + "实时帧换掉了快照对象 ⇒ 来源必须是 live(zutand 每次 set 都换引用)" +); + +// ── ③ 后台思考会话:按它自己的会话补拉、落进它自己的桶 ──────────────────── +stubFetch({ + data: { sessionId: BACKTHINK_SESSION_ID, graph: roundGraph("sess-backthink-3", BACKTHINK_SESSION_ID) }, +}); +const bt = await adminFlowGraph(BACKTHINK_SESSION_ID); +ok(bt.graph !== null, "后台思考会话的快照非空"); +useAppStore.getState().setFlowGraph(bt.graph!, BACKTHINK_SESSION_ID); +same( + useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id, + "sess-backthink-3", + "后台思考的图落进它自己的桶(裁决 16.4.2:不顶掉当前会话的图)" +); +same( + useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id, + "web-8", + "补拉后台思考的图绝不覆盖主会话的图" +); + +// ── ④ 后端如实回 null:不造图、不写桶、空态保持诚实 ───────────────────── +useAppStore.getState().resetChat(); +same( + needsFlowSnapshot(BACKTHINK_SESSION_ID, useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]), + true, + "清空后仍需要补拉" +); +stubFetch({ data: { sessionId: BACKTHINK_SESSION_ID, graph: null } }); +const empty = await adminFlowGraph(BACKTHINK_SESSION_ID); +same(empty.graph, null, "后端回 null ⇒ 客户端拿到的就是 null(不编图)"); +// hook 的写法(`if (!cancelled && resp.graph)`)保证 null 不写桶: +if (empty.graph) useAppStore.getState().setFlowGraph(empty.graph, BACKTHINK_SESSION_ID); +same( + useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID], + undefined, + "graph:null 绝不写进图桶(否则面板就会画出一张假图)" +); +same( + flowGraphSourceOf(useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID], null), + null, + "没有图 ⇒ 来源是 null(空态)" +); + +// ── ⑤ 会话 id 为空时是空操作 ──────────────────────────────────────────── +same(needsFlowSnapshot("", undefined), false, "没有会话 id 时不补拉"); + +globalThis.fetch = originalFetch; +globalThis.localStorage = originalLocalStorage; + +console.log(`flow snapshot logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/useFlowSnapshot.ts b/frontend/web/src/admin/modules/flow/useFlowSnapshot.ts new file mode 100644 index 0000000..ba0be78 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/useFlowSnapshot.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { useAppStore } from "../../../store/useAppStore"; +import { adminFlowGraph } from "../../../lib/api"; +import type { FlowGraph } from "../../../lib/types"; + +/** 当前屏幕上的那张图是哪来的(契约 v0.71 §4.11(b)-1)。 + * - `live` = 本次页面存活期间**实时收到**的 `graph_update` 帧; + * - `snapshot` = 打开页面时**只读拉取**的「最近一轮」快照; + * - `null` = 该会话还没有任何图(诚实空态)。 */ +export type FlowGraphSource = "live" | "snapshot" | null; + +/** 要不要为这个会话补拉「最近一轮快照」——**只有本会话还没有任何图时**才拉。 + * + * 已有图(实时帧先到了)时**绝不**补拉:实时图更新、且是「正在跑的那一轮」的 + * 唯一来源;用快照覆盖它就是把旧一轮顶掉正在跑的一轮。抽成纯函数是为了让这条 + * 判定能被逻辑测试直接钉住(组件/hook 的 effect 在本项目无 jsdom,跑不了)。 */ +export function needsFlowSnapshot(sessionId: string, graph: FlowGraph | null | undefined): boolean { + return Boolean(sessionId) && !graph; +} + +/** 把一个会话「最近一轮」的链路图快照补进 store,并返回当前屏幕上这张图的来源。 + * + * 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,而 store 的图桶 + * (`flowGraphs`)**不在 persist 的 partialize 里** ⇒ 轮次结束后才打开的页面、 + * 或刷新过的页面,桶里是空的,管理端 Flow / Agents 页与后台思考行因此永远显示 + * 空态(主人第二次报的就是这个)。本 hook 在页面挂载时**只读拉一次**最近一轮。 + * + * 四条刻意的口径: + * 1. **只在「本会话还没有任何图」时拉**(`graph` 为空)。已有实时图时**绝不** + * 用快照覆盖它 —— 实时帧更新、而且是「正在跑的那一轮」的唯一来源;补拉只补 + * 冷启动的洞。 + * 2. **拉回 `null` 不重试、不造图**:后端保证 `graph` 键恒在(没有图时是 null) + * ⇒ 只有真拿到图才写桶;空态因此是稳定的诚实空态。 + * 3. **失败静默**:网络/401/`500 flow_unavailable` 一律不动界面。 + * 4. **来源可分辨**:本 hook 记住**自己写进去的那个对象引用**,屏幕上还是同一个 + * 引用 ⇒ 它是快照;被后来的实时帧换掉 ⇒ 它是实时图(zustand 每次 set 都换对象)。 + * 没有这个区分,面板就只能假装快照是实时的 —— 而两者是不同的事实。 + * + * `sessionId` 为空时是空操作(调用方还没解析出会话)。 */ +export function useFlowSnapshot(sessionId: string): FlowGraphSource { + const setFlowGraph = useAppStore((s) => s.setFlowGraph); + const graph = useAppStore((s) => (sessionId ? s.flowGraphs[sessionId] : undefined)); + const [pulled, setPulled] = useState(null); + + useEffect(() => { + if (!needsFlowSnapshot(sessionId, graph)) return; + let cancelled = false; + adminFlowGraph(sessionId) + .then((resp) => { + if (cancelled || !resp.graph) return; + setFlowGraph(resp.graph, sessionId); + setPulled(resp.graph); + }) + .catch(() => { + /* 只读补拉失败不改变界面:空态/实时图照旧 */ + }); + return () => { + cancelled = true; + }; + }, [sessionId, graph, setFlowGraph]); + + if (!graph) return null; + return flowGraphSourceOf(graph, pulled); +} + +/** 屏幕上这张图的来源(纯函数,供逻辑测试直接钉住「有快照 ≠ 空态」)。 + * `pulled` 是**本 hook 自己写进去的那个对象引用**:屏幕上还是同一个引用 ⇒ + * 它来自快照;被后来的实时帧换掉(zustand 每次 set 都换对象)⇒ 它是实时图。 */ +export function flowGraphSourceOf( + graph: FlowGraph | null | undefined, + pulled: FlowGraph | null +): FlowGraphSource { + if (!graph) return null; + return pulled === graph ? "snapshot" : "live"; +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index c3eb569..12e58cc 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -117,6 +117,7 @@ export type { ChannelEventQuery } from "./api/adminChannelEvents"; export { adminBackthink, adminConfigFacts, + adminFlowGraph, adminListNotifications, adminObservabilityAudit, adminObservabilityLogs, @@ -128,3 +129,4 @@ export { adminSystemRotateJWT, adminSystemStorage, } from "./api/adminSystem"; +export type { AdminFlowGraphResponse } from "./api/adminSystem"; diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts index 66cef7d..30f742c 100644 --- a/frontend/web/src/lib/api/adminSystem.ts +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -9,6 +9,7 @@ import type { AuditRecord, AdminConfigFacts, BackthinkItem, + FlowGraph, SystemBackupResp, SystemRestoreResp, SystemRevokeKeysResp, @@ -46,6 +47,32 @@ export function adminBackthink(limit?: number): Promise { return request(`/admin/backthink${limitQuery(limit)}`); } +/** 最近一轮链路图快照的后端响应(契约 v0.71 §4.11(a))。`graph` **恒在** + * (`null` 而不是缺席):`null` = 该会话**确实**还没有图,客户端必须据此 + * 渲染空态,绝不允许补画任何节点或假图。 */ +export interface AdminFlowGraphResponse { + sessionId: string; + graph: FlowGraph | null; +} + +/** GET /api/v1/admin/flow/graph?sessionId= — **最近一轮**链路图快照(契约 + * v0.71 §4.11,只读、不审计,owner/admin 档)。 + * + * 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,且前端图桶 + * (`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或「刷新过页面」 + * 此前只能看到空态。打开页面时拉一次即可补上最近一轮。 + * + * ⚠️ 它只回**每会话最近一轮**:后台思考行上点开的图可能不是该行那一轮 + * (多轮历史回放仍未实现,`design/19 §4` P3)⇒ 面板必须在图上说明。 + * + * `sessionId` 省略时后端默认主会话 `web`。 */ +export function adminFlowGraph(sessionId: string): Promise { + const qs = new URLSearchParams(); + if (sessionId) qs.set("sessionId", sessionId); + const q = qs.toString(); + return request(`/admin/flow/graph${q ? `?${q}` : ""}`); +} + /** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)): * 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的 * 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死), diff --git a/internal/runtime/loop/flow.go b/internal/runtime/loop/flow.go index ef530dd..43db141 100644 --- a/internal/runtime/loop/flow.go +++ b/internal/runtime/loop/flow.go @@ -68,8 +68,10 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) { } // EndRound 用回复播种一个 output 节点并发布最终帧, -// 以此收尾该轮次。它保留该图,使错过最终帧的客户端 -// 刷新时能重放最后一份快照。 +// 以此收尾该轮次。它保留该图,使错过最终帧的客户端能重放最后一份快照 —— +// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧: +// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐 +// (`graph_update` 本身不重放)。 func (p *FlowPublisher) EndRound(sessionID, outputText string) { if sessionID == "" || !p.allowed(sessionID) { return @@ -142,6 +144,34 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) { p.publishLocked(g) } +// LastGraph 返回 sessionID **最近一轮**链路图的一份只读深拷贝快照,供 +// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的 +// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的 +// 最小一步)。 +// +// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在 +// 管理面板里凭空造出一帧 `graph_update`,也绝不改任何会话的实时图。 +// +// 该会话还没有图时返回 nil:从未跑过带 span 的轮次、或该会话本就**不在 +// 发布会话范围内**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图, +// 隔离渠道会话因此天然查不到)。调用方**必须**把 nil 读作「确实没有图」, +// 绝不允许据此补画任何节点或假图。 +// +// 返回的是深拷贝(`trace.FlowGraph.Clone`):读者在锁外编码时, +// 活图可能正被下一帧 span 就地合并,共享底层 map/切片会撕裂。 +func (p *FlowPublisher) LastGraph(sessionID string) *trace.FlowGraph { + if p == nil || sessionID == "" { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + g := p.graphs[sessionID] + if g == nil { + return nil + } + return g.Snapshot().Clone() +} + // publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。 func (p *FlowPublisher) publishLocked(g *trace.RunGraph) { if p.publish == nil { diff --git a/internal/runtime/loop/flow_snapshot_test.go b/internal/runtime/loop/flow_snapshot_test.go new file mode 100644 index 0000000..bd6abc0 --- /dev/null +++ b/internal/runtime/loop/flow_snapshot_test.go @@ -0,0 +1,179 @@ +package loop + +import ( + "context" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// 本文件钉住契约 v0.71 的只读快照出口 `FlowPublisher.LastGraph`: +// 「轮次结束后才打开的页面也能看到该轮的图」。它是 loop 侧的半边; +// 网关侧的半边(`GET /api/v1/admin/flow/graph`)在 +// internal/services/gateway/admin_flow_graph_test.go。 + +// countingPublisher 接上捕获式 publish 并计数,用来断言 +// 「只读查询不发布」。 +func countingPublisher(allow func(string) bool) (*FlowPublisher, func() int) { + var mu sync.Mutex + frames := 0 + p := NewFlowPublisher(func(string, any) { + mu.Lock() + frames++ + mu.Unlock() + }, allow) + return p, func() int { + mu.Lock() + defer mu.Unlock() + return frames + } +} + +// TestFlowPublisher_LastGraph_AfterRoundEnded 跑一轮**真实**循环,然后在 +// Run 返回之后(= 所有实时帧都已发完、晚开的页面一帧都收不到)取快照: +// 它必须仍然带上该轮的主干与回复 —— 这就是主人报的那个场景。 +func TestFlowPublisher_LastGraph_AfterRoundEnded(t *testing.T) { + flow, frames := countingPublisher(func(string) bool { return true }) + call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}` + ad := &mockAdapter{chat: []mgResp{ + {resp: model.Response{Content: call}}, + {resp: model.Response{Content: "Echoed!"}}, + }} + l := New(Options{ + Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "echo", result: "hi"})), + MaxRounds: 8, + Flow: flow, + Tracer: trace.New(nil), + }) + if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")}); err != nil { + t.Fatalf("Run: %v", err) + } + before := frames() + + g := flow.LastGraph("web") + if g == nil { + t.Fatal("LastGraph = nil,但该会话刚跑完一整轮(轮次结束后打开的页面将看不到图)") + } + kinds := map[string]bool{} + for _, n := range g.Nodes { + kinds[n.Kind] = true + } + for _, want := range []string{"input", "llm", "tool", "output"} { + if !kinds[want] { + t.Fatalf("快照缺少 %q 节点:%+v", want, g.Nodes) + } + } + if g.SessionID != "web" { + t.Fatalf("SessionID = %q, want web", g.SessionID) + } + if g.EndedAt.IsZero() { + t.Fatal("EndedAt 为零值:快照不是收轮之后的那一份") + } + // 只读查询绝不发布 —— 否则补拉会在面板里凭空造出一帧 graph_update。 + if got := frames(); got != before { + t.Fatalf("LastGraph 发布了 %d 帧新图(%d → %d)", got-before, before, got) + } +} + +// TestFlowPublisher_LastGraph_KeepsLatestRound 钉住「最近一轮」的语义: +// 第二轮开始后快照就是第二轮的,而不是第一轮残留。 +func TestFlowPublisher_LastGraph_KeepsLatestRound(t *testing.T) { + flow, _ := countingPublisher(func(string) bool { return true }) + flow.StartRound("web", "第一轮") + flow.EndRound("web", "第一轮回复") + first := flow.LastGraph("web") + if first == nil || first.ID != "web-1" { + t.Fatalf("第一轮快照 = %+v, want id web-1", first) + } + flow.StartRound("web", "第二轮") + flow.EndRound("web", "第二轮回复") + second := flow.LastGraph("web") + if second == nil || second.ID != "web-2" { + t.Fatalf("第二轮快照 = %+v, want id web-2", second) + } + for _, n := range second.Nodes { + if n.Kind == "input" && n.Label == "第一轮" { + t.Fatalf("快照仍是第一轮的输入节点: %+v", n) + } + } +} + +// TestFlowPublisher_LastGraph_EmptyCasesAreNil 钉住「确实没有图」的三种形态: +// nil 接收者、空会话 id、从未建过图的会话,一律 nil(调用方据此渲染诚实空态, +// 绝不允许补画节点)。 +func TestFlowPublisher_LastGraph_EmptyCasesAreNil(t *testing.T) { + flow, _ := countingPublisher(func(string) bool { return true }) + flow.StartRound("web", "你好") + + var nilPub *FlowPublisher + if g := nilPub.LastGraph("web"); g != nil { + t.Fatalf("nil 接收者应返回 nil,得到 %+v", g) + } + if g := flow.LastGraph(""); g != nil { + t.Fatalf("空会话 id 应返回 nil,得到 %+v", g) + } + if g := flow.LastGraph("sess-never-ran"); g != nil { + t.Fatalf("从未建图的会话应返回 nil,得到 %+v", g) + } +} + +// TestFlowPublisher_LastGraph_WhitelistStillApplies 钉住只读查询**不绕过** +// 发布会话白名单(design/19 §1 裁决 01.4.5 未被本版改动):白名单外的会话 +// 从未建图 ⇒ 查询也拿不到。 +func TestFlowPublisher_LastGraph_WhitelistStillApplies(t *testing.T) { + flow, _ := countingPublisher(func(session string) bool { return session == "web" }) + flow.StartRound("web", "你好") + flow.StartRound("sess-im-stranger", "群里的消息") + + if g := flow.LastGraph("web"); g == nil { + t.Fatal("白名单内的会话应当有图") + } + if g := flow.LastGraph("sess-im-stranger"); g != nil { + t.Fatalf("白名单外的会话不该有图,得到 %+v", g) + } +} + +// TestFlowPublisher_LastGraph_IsDetachedCopy 钉住快照是**深拷贝**: +// 读者拿到之后在锁外编码时,活图可能正被下一帧 span 就地合并 +// (RunGraph.merge 往同一个 map 写、往同一个切片追加)—— 共享底层存储 +// 会撕裂,Go 里并发读写 map 直接是致命错误。因此改读者手里的那份, +// 绝不能影响发布器内部的图。 +func TestFlowPublisher_LastGraph_IsDetachedCopy(t *testing.T) { + flow, _ := countingPublisher(func(string) bool { return true }) + flow.StartRound("web", "你好") + flow.MarkInputSteered("web") + + first := flow.LastGraph("web") + if first == nil { + t.Fatal("LastGraph = nil") + } + for i := range first.Nodes { + if first.Nodes[i].ID != "input" { + continue + } + first.Nodes[i].Props["tampered"] = true + first.Nodes[i].Props["steered"] = false + } + first.Nodes = append(first.Nodes, trace.FlowNode{ID: "ghost", Kind: "input"}) + + second := flow.LastGraph("web") + if len(second.Nodes) == len(first.Nodes) { + t.Fatal("第二次快照的节点数与读者改过的切片相同 ⇒ 返回的不是独立副本") + } + for _, n := range second.Nodes { + if n.ID == "ghost" { + t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝") + } + if n.ID != "input" { + continue + } + if _, ok := n.Props["tampered"]; ok { + t.Fatal("读者改写 props 影响到了内部图 ⇒ props 未深拷贝") + } + if steered, ok := n.Props["steered"].(bool); !ok || !steered { + t.Fatalf("内部图原有的 steered 标记被读者改坏: %+v", n.Props) + } + } +} diff --git a/internal/runtime/trace/graph.go b/internal/runtime/trace/graph.go index e000ec3..60bf542 100644 --- a/internal/runtime/trace/graph.go +++ b/internal/runtime/trace/graph.go @@ -175,3 +175,51 @@ func (g *RunGraph) Snapshot() *FlowGraph { EndedAt: g.ended, } } + +// Clone 深拷贝一份图,供**锁外**的读者使用(契约 v0.71 的 +// `GET /api/v1/admin/flow/graph` 就近取材于 `loop.FlowPublisher.LastGraph`)。 +// +// 为什么必须深拷贝:`Snapshot` 只复制节点结构体,`Props` 映射与 `Log` 切片 +// 仍与活图**共享底层存储**;而 `RunGraph.Upsert` 对同一个节点是**就地**合并 +// (`merge` 往同一个 map 写键、往同一个切片追加)。发布路径在持有发布器 +// 互斥锁时就把图编码掉了,因此共享无碍;但只读查询把图交出去之后还要在 +// 锁外编码,与下一帧 span 的合并并发 ⇒ 会读到撕裂的 map(Go 里并发读写 +// map 直接是致命错误)。深拷贝把这份共享切断,且只发生在人为的只读查询上, +// 不在实时发布路径上,因此不影响每帧开销。 +func (g *FlowGraph) Clone() *FlowGraph { + if g == nil { + return nil + } + out := &FlowGraph{ + ID: g.ID, + Nodes: make([]FlowNode, 0, len(g.Nodes)), + Edges: append([]FlowEdge(nil), g.Edges...), + SessionID: g.SessionID, + StartedAt: g.StartedAt, + EndedAt: g.EndedAt, + } + for _, n := range g.Nodes { + cp := n + if n.Props != nil { + cp.Props = make(map[string]any, len(n.Props)) + for k, v := range n.Props { + cp.Props[k] = v + } + } + if n.Log != nil { + cp.Log = make([]Event, len(n.Log)) + for i, e := range n.Log { + ev := e + if e.Props != nil { + ev.Props = make(map[string]any, len(e.Props)) + for k, v := range e.Props { + ev.Props[k] = v + } + } + cp.Log[i] = ev + } + } + out.Nodes = append(out.Nodes, cp) + } + return out +} diff --git a/internal/services/gateway/admin_flow.go b/internal/services/gateway/admin_flow.go new file mode 100644 index 0000000..c62b1e5 --- /dev/null +++ b/internal/services/gateway/admin_flow.go @@ -0,0 +1,70 @@ +package gateway + +import ( + "net/http" + "strings" + + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话 +// (`internal/runtime/history.MainSessionID`)。它与网关其余默认会话 +// 口径同源(见 ws_hub_turn.go 的 streamChat),因此不新增第二个「默认会话」 +// 常量。 +const mainSessionID = history.MainSessionID + +// flowUnavailableCode 是图源未接线时的机器可读错误码。它**不**回 +// graph:null:那会把「图源没接线」伪装成「这一轮确实没有图」,与 +// usage_unavailable / channel_events_unavailable 同一口径。 +const flowUnavailableCode = "flow_unavailable" + +// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体(契约 v0.71)。 +// +// Graph 为 nil 表示「该会话确实还没有图」——这是一个**诚实的空态**,不是错误: +// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b)-3)。 +// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与 +// 「这个键是空」。 +type adminFlowGraphResponse struct { + SessionID string `json:"sessionId"` + // Graph 是**最近一轮**的图快照,与 `graph_update` 帧的 data 逐字同形 + // (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒ + // 线上 JSON `null`)与「有图」的区分就是本字段的语义。 + Graph any `json:"graph"` +} + +// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71, +// design/19 §4 的 P3「多轮历史回放」的最小一步)。 +// +// 它回答的是主人第二次报的那个洞:`graph_update` **只走实时帧、从不重放**, +// 而前端图桶(`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或 +// 「刷新过页面」永远空着。本端点让打开页面时能**只读拉一次**最近一轮的图。 +// +// 三条硬约束(与 §4.11(b) 逐条对应): +// 1. **只读**:经 `FlowGraphSource` 拿快照,网关不发布、不改图、不动任何会话; +// 2. **不画假图**:该会话没有图 ⇒ `graph:null`;图源未接线 ⇒ `500 flow_unavailable` +// (**不是** graph:null,见 flowUnavailableCode); +// 3. **只回最近一轮**:更早轮次的历史回放仍未实现(design/19 §4 P3)—— +// 因此面板必须在图上说明「这不是你要的那一行那轮的图」。 +// +// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」 +// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed +// 默认档 = 只发 owner+admin)是同一档,因此**不存在**「帧看不到、REST 看得到」的 +// 旁路。本端点只读、不审计。 +func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId")) + if sessionID == "" { + sessionID = mainSessionID + } + if s.flow == nil { + s.log.Warn("flow graph source not wired", "session", sessionID) + writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired")) + return + } + // Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。 + var graph any + if snap := s.flow.LastGraph(sessionID); snap != nil { + graph = snap + } + writeJSON(w, http.StatusOK, adminFlowGraphResponse{SessionID: sessionID, Graph: graph}) +} diff --git a/internal/services/gateway/admin_flow_graph_test.go b/internal/services/gateway/admin_flow_graph_test.go new file mode 100644 index 0000000..51de60c --- /dev/null +++ b/internal/services/gateway/admin_flow_graph_test.go @@ -0,0 +1,283 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// 本文件钉住契约 v0.71 的唯一新增面:`GET /api/v1/admin/flow/graph` —— +// 「最近一轮链路图快照」的只读出口。它修的是主人第二次报的那个洞: +// `graph_update` 只走实时帧、从不重放(前端图桶也不持久化)⇒ 轮次结束后 +// 才打开的页面永远空着。 + +// flowTestEnv 构建一个接了**真** *loop.FlowPublisher 作图源的网关。 +// 与 newEnv 一样接真 bus、真 JWT,只是多注入了 Flow —— 因此测的是 +// 生产那条路径(发布器 → 只读查询),不是假实现。 +type flowTestEnv struct { + ts *httptest.Server + auth *auth.JWTService + flow *loop.FlowPublisher + bus bus.Bus + graph *trace.RunGraph +} + +func newFlowTestEnv(t *testing.T, allow func(string) bool) *flowTestEnv { + t.Helper() + jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) + b := bus.New() + // 图的发布出口在这里只用于「证明查询面不影响发布」:收集每一帧发布。 + pub := loop.NewFlowPublisher(func(string, any) {}, allow) + cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} + srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub}) + if err != nil { + t.Fatalf("new server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return &flowTestEnv{ts: ts, auth: jwt, flow: pub, bus: b} +} + +func (e *flowTestEnv) token(t *testing.T, role auth.Role) string { + t.Helper() + tok, err := e.auth.Sign("acct", role, time.Hour) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return tok +} + +// runFinishedRound 让发布器跑完**一整轮**(起轮 → span 起止 → 收轮), +// 模拟「轮次已经结束」的时刻。返回时该轮所有实时帧都已发完 —— +// 因此此刻打开页面的客户端一帧都收不到,只能靠只读快照。 +func (e *flowTestEnv) runFinishedRound(t *testing.T, session string) { + t.Helper() + e.flow.StartRound(session, "帮我查一下天气") + sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()} + e.flow.SpanStarted(session, sp) + sp.Finish(trace.SpanStatusOK) + e.flow.SpanFinished(session, sp) + e.flow.EndRound(session, "今天晴,26 度。") +} + +// flowGraphData 解码 {data:{sessionId,graph}} 信封;`graph` 键必须**恒在** +// (没有图时是 JSON null),因为客户端靠它区分「没有图」与「字段缺失」。 +func flowGraphData(t *testing.T, body []byte) (string, map[string]json.RawMessage, bool) { + t.Helper() + env := decodeBody(t, body) + var data struct { + SessionID string `json:"sessionId"` + Graph map[string]json.RawMessage `json:"graph"` + } + if err := json.Unmarshal(env["data"], &data); err != nil { + t.Fatalf("decode data %s: %v", body, err) + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(env["data"], &envelope); err != nil { + t.Fatalf("decode data envelope %s: %v", body, err) + } + _, present := envelope["graph"] + return data.SessionID, data.Graph, present +} + +// TestAdminFlowGraph_LatePageSeesLastRound 是主人的原话场景: +// **轮次结束后才打开页面**(本次测试里没有任何 WS 客户端连过)也必须能看到 +// 该轮的图。它是本片的核心断言 —— 去掉只读快照通路它必须失败。 +func TestAdminFlowGraph_LatePageSeesLastRound(t *testing.T) { + env := newFlowTestEnv(t, func(string) bool { return true }) + env.runFinishedRound(t, "web") + + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + sessionID, graph, present := flowGraphData(t, body) + if !present { + t.Fatalf("`graph` key must always be present (null when empty): %s", body) + } + if graph == nil { + t.Fatalf("graph = null,但该会话刚跑完一整轮 ⇒ 轮次结束后打开的页面看不到图(这正是本次要修的那个洞): %s", body) + } + if sessionID != "web" { + t.Fatalf("sessionId = %q, want web", sessionID) + } + var nodes []map[string]json.RawMessage + if err := json.Unmarshal(graph["nodes"], &nodes); err != nil { + t.Fatalf("decode nodes: %v (graph=%s)", err, graph) + } + if len(nodes) == 0 { + t.Fatalf("nodes 为空,快照没带上该轮的节点: %s", graph) + } + // 主干两端必须在:input(用户输入)与 output(回复)。 + kinds := map[string]bool{} + for _, n := range nodes { + var kind string + _ = json.Unmarshal(n["kind"], &kind) + kinds[kind] = true + } + if !kinds["input"] || !kinds["output"] { + t.Fatalf("快照缺少主干端点 input/output(kinds=%v): %s", kinds, graph) + } + // 图的归属必须是它自己的会话 id —— 前端按这个键入桶(裁决 16.4.2)。 + var gsid string + _ = json.Unmarshal(graph["sessionId"], &gsid) + if gsid != "web" { + t.Fatalf("graph.sessionId = %q, want web", gsid) + } +} + +// TestAdminFlowGraph_NoGraphYetIsHonestNull 钉住空态:该会话**确实**还没有图时 +// 回 200 + `graph:null`(键恒在)—— 绝不允许画假图、也绝不把「没有图」当错误。 +func TestAdminFlowGraph_NoGraphYetIsHonestNull(t *testing.T) { + env := newFlowTestEnv(t, func(string) bool { return true }) + + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleAdmin)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + _, graph, present := flowGraphData(t, body) + if !present { + t.Fatalf("`graph` 键必须恒在(空时是 null): %s", body) + } + if graph != nil { + t.Fatalf("graph = %s,但该会话从未跑过轮次 ⇒ 必须如实为 null", body) + } +} + +// TestAdminFlowGraph_DefaultSessionIsMain 钉住 `?sessionId=` 省略时落到主会话 +// `web`(与网关其余默认会话口径同源)。 +func TestAdminFlowGraph_DefaultSessionIsMain(t *testing.T) { + env := newFlowTestEnv(t, func(string) bool { return true }) + env.runFinishedRound(t, "web") + + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + sessionID, graph, _ := flowGraphData(t, body) + if sessionID != "web" || graph == nil { + t.Fatalf("省略 sessionId 应落到主会话 web 并带回它的图;got sessionId=%q graph=%v (body=%s)", sessionID, graph, body) + } +} + +// TestAdminFlowGraph_UnwiredSourceIsHonest 钉住接线缺失的诚实口径:图源未注入时 +// 回 `500 flow_unavailable`,**不是** `graph:null` —— 后者会把「图源没接线」伪装成 +// 「这一轮确实没有图」,正是项目明令禁止的那类静默降级。 +func TestAdminFlowGraph_UnwiredSourceIsHonest(t *testing.T) { + env := newEnv(t, nil, false) // newEnv 不注入 Flow + + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) + } + envErr := decodeBody(t, body) + var eobj struct { + Code string `json:"code"` + } + if err := json.Unmarshal(envErr["error"], &eobj); err != nil { + t.Fatalf("error envelope: %v (body=%s)", err, body) + } + if eobj.Code != flowUnavailableCode { + t.Fatalf("error code = %q, want %q", eobj.Code, flowUnavailableCode) + } +} + +// TestAdminFlowGraph_RoleGate 钉住角色门与同一张图的 WS 帧同档 +// (owner/admin 可读、viewer 403、匿名 401):不存在「帧收不到、REST 看得到」的旁路。 +func TestAdminFlowGraph_RoleGate(t *testing.T) { + env := newFlowTestEnv(t, func(string) bool { return true }) + env.runFinishedRound(t, "web") + url := env.ts.URL + "/api/v1/admin/flow/graph" + + resp, body := doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleAdmin)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + resp, body = doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleViewer)) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body) + } + resp, _ = doReq(t, http.MethodGet, url, nil, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anonymous status = %d, want 401", resp.StatusCode) + } +} + +// TestAdminFlowGraph_ReadDoesNotPublish 钉住「只读查询不改任何会话」: +// 拉快照不产生新的 bus 事件(否则补拉会在管理面板里凭空造出一帧 graph_update)。 +func TestAdminFlowGraph_ReadDoesNotPublish(t *testing.T) { + published := 0 + jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) + b := bus.New() + pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true }) + cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} + srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub}) + if err != nil { + t.Fatalf("new server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + + pub.StartRound("web", "你好") + pub.EndRound("web", "你好呀") + before := published + + tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour) + if err != nil { + t.Fatalf("sign token: %v", err) + } + for i := 0; i < 3; i++ { + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + } + if published != before { + t.Fatalf("补拉发布了 %d 帧新图(%d → %d),只读查询必须不发布", published-before, before, published) + } +} + +// TestAdminFlowGraph_OutsideWhitelistStaysNil 钉住图源白名单仍然生效: +// 发布会话范围之外的会话(隔离渠道会话)从来没有建过图,因此只读查询 +// **不可能**把它们变成可查的(design/19 §1 裁决 01.4.5 未被本版改动)。 +func TestAdminFlowGraph_OutsideWhitelistStaysNil(t *testing.T) { + env := newFlowTestEnv(t, func(session string) bool { return session == "web" }) + env.runFinishedRound(t, "web") + // 白名单外的会话:起轮与 span 都被发布器直接丢弃 ⇒ 从不建图。 + env.runFinishedRound(t, "sess-im-stranger") + + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-im-stranger", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + _, graph, present := flowGraphData(t, body) + if !present { + t.Fatalf("`graph` 键必须恒在: %s", body) + } + if graph != nil { + t.Fatalf("白名单外的会话不该有图,却拿到了 %s", body) + } + // 反向:白名单内的会话照常可查(保证上面的 null 不是「端点整体坏了」)。 + _, ok, _ := flowGraphData(t, mustBody(t, env, "web")) + if ok == nil { + t.Fatal("白名单内的 web 会话应当仍有图") + } +} + +// mustBody 直接问端点并把响应体交回给调用方(上面反向断言的辅助)。 +func mustBody(t *testing.T, env *flowTestEnv, session string) []byte { + t.Helper() + resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId="+session, nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + return body +} diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index a719f5b..813b051 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -21,6 +21,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) @@ -284,3 +285,23 @@ type ChannelEventStore interface { // 的表,不含内存里待 flush 的部分(即 §4.3 的「近似值」)。 Counters(ctx context.Context, platform string) ([]events.Counter, error) } + +// FlowGraphSource 是**最近一轮链路图**的只读出口(契约 v0.71 的 +// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3)。生产实现就是 +// `*loop.FlowPublisher`(`internal/runtime/loop/flow.go` 的 `LastGraph`), +// 组合根把**同一个**发布器既交给 loop/orchestration(写入面:每帧 span +// 发布 `ws.graph_update`)也交给网关(本查询面)。 +// +// 它刻意只声明**一个读方法**:发布、建图、轮次计数与发布会话白名单都留在 +// 发布器自己手里,管理查询面拿不到、也不该拿到它们(最小权限)——因此 +// 只读查询**不可能**改变任何会话的实时图,也不可能把面板看不见的会话 +// 变成可查的(白名单外的会话从未建图,`LastGraph` 自然回 nil)。 +// +// 为 nil 时端点诚实作答 500 `flow_unavailable`,绝不回 `graph:null` +// (那会把「图源没接线」伪装成「这一轮确实没有图」—— +// 与 `usage_unavailable` / `channel_events_unavailable` 同一口径)。 +type FlowGraphSource interface { + // LastGraph 返回 sessionID 最近一轮图的一份只读快照;该会话还没有图 + // (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。 + LastGraph(sessionID string) *trace.FlowGraph +} diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 1f9b4b3..6d3c58e 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -154,4 +154,10 @@ type Deps struct { // 它刻意是编排器的构造期旋钮而不是一个全局:真并行只由显式声明 // (上行 `chat.parallel`)触发,因此调大它不会改变任何未声明并行的客户端行为。 MaxParallelTurns int + // Flow 是「最近一轮链路图」的只读出口(契约 v0.71 的 + // GET /api/v1/admin/flow/graph)。组合根注入的正是交给 loop 的那个 + // *loop.FlowPublisher —— 同一个对象,因此查询面读到的就是实时发布的那张图。 + // 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回 + // graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。 + Flow FlowGraphSource } diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 42d87d4..6d977d5 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -182,6 +182,11 @@ func (s *Server) registerAdminRoutes( mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog))) mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers))) mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink))) + // 最近一轮链路图快照(**v0.71**):`graph_update` 只走实时帧、从不重放, + // 前端图桶也不持久化 ⇒ 「轮次结束后才打开的页面」此前只能看到空态。 + // 只读、不审计;与同一张图的 WS 下行帧同档(只发 owner|admin, + // design/19 §3「flow 可视化仅 owner/admin」),因此不存在绕过帧门的旁路。 + mux.Handle("GET /api/v1/admin/flow/graph", writeAuth(http.HandlerFunc(s.adminFlowGraph))) // 配置事实面(切片 P3,**v0.68**):记忆整理 7 键 + 安静时段两键的**进程生效值** // 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页 // (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。 diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index c2c2a19..1d45a17 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -100,6 +100,9 @@ type Server struct { // maxParallelTurns 是单会话在途并行路数的上限(见 Deps.MaxParallelTurns)。 // 零值 = 未接线 ⇒ 编排器用默认 2。 maxParallelTurns int + // flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。 + // 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。 + flow FlowGraphSource // envResolver 解析会话每轮的 loop 环境(其角色)。见 // persona_env.go / design/22 §4.5。 envResolver turnEnvResolver @@ -152,6 +155,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { vector: deps.Vector, log: sourcedLog(deps.Log), maxParallelTurns: deps.MaxParallelTurns, + flow: deps.Flow, } // 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner // 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),