From 5ee5f9126ff15e5c63345ad3aedfa79f657318e3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:59:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Flow=20=E9=A1=B5=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=BD=B4=E8=A7=86=E5=9B=BE=EF=BC=88W2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新) - 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、 块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」 - 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」 - 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测 --- frontend/web/src/admin/modules/Flow.tsx | 372 ++++++++++++------ .../admin/modules/flow/FlowSnapshotHint.tsx | 31 +- .../web/src/admin/modules/flow/LaneTracks.tsx | 11 +- .../src/admin/modules/flow/TimelineView.tsx | 278 +++++++++++++ .../modules/flow/flowTimeline.render.test.ts | 368 +++++++++++++++++ .../admin/modules/flow/flowTimeline.test.ts | 336 ++++++++++++++++ .../src/admin/modules/flow/flowTimeline.ts | 305 ++++++++++++++ .../src/admin/modules/flow/useFlowTimeline.ts | 197 ++++++++++ frontend/web/src/lib/api.ts | 9 +- frontend/web/src/lib/api/adminSystem.ts | 51 +++ 10 files changed, 1831 insertions(+), 127 deletions(-) create mode 100644 frontend/web/src/admin/modules/flow/TimelineView.tsx create mode 100644 frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts create mode 100644 frontend/web/src/admin/modules/flow/flowTimeline.test.ts create mode 100644 frontend/web/src/admin/modules/flow/flowTimeline.ts create mode 100644 frontend/web/src/admin/modules/flow/useFlowTimeline.ts diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index 7e785fb..dfe9eee 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useAppStore } from "../../store/useAppStore"; import type { AgentStatus, FlowGraph } from "../../lib/types"; import { CardGrid, Notice, PageHeader, StatCard } from "../components"; @@ -11,15 +11,32 @@ import { LaneTracks } from "./flow/LaneTracks"; import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot"; import { useFlowTurns } from "./flow/useFlowTurns"; +import { useFlowTimeline, type FlowTimelineState } from "./flow/useFlowTimeline"; +import { FlowTimelineView, type TimelineBinding } from "./flow/TimelineView"; import { FlowHistoryHint, FlowTurnPicker } from "./flow/FlowTurnPicker"; import { FlowSessionPicker } from "./flow/FlowSessionPicker"; import { LATEST_TURN, resolveShownGraph } from "./flow/flowHistory"; import { flowPageData, type FlowSessionOption } from "./flow/flowSession"; +import { + orderRounds, + toggleRoundExpansion, + type FlowTimelineOrder, +} from "./flow/flowTimeline"; import { groupFlowLanes } from "./flow/lanes"; import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone"; import { MAIN_SESSION_ID } from "../../store/sessionCache"; import type { AdminFlowTurn } from "../../lib/api"; +/** Flow 页的**视图模式**(`design/19 §7.2`):保留既有「最近一轮」,新增「时间轴」。 + * 两者**可切**,谁也不替换谁(老视图逐字不变 ⇒ 老客户端与老测试零影响)。 */ +export type FlowView = "latest" | "timeline"; + +/** 视图切换的两个页签(文案即事实:一个是一轮,一个是多轮按时间串)。 */ +const VIEW_TABS: ReadonlyArray<{ id: FlowView; label: string; hint: string }> = [ + { id: "latest", label: "最近一轮", hint: "实时 / 快照的最近一轮(含并列泳道与历史轮回放)" }, + { id: "timeline", label: "时间轴", hint: "保留期内的多轮按时间顺序排列,每回合一个可折叠块" }, +]; + /** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 —— * 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器), * 节点点开看参数 / 结果 / 实时日志。 @@ -35,13 +52,22 @@ import type { AdminFlowTurn } from "../../lib/api"; * 说明条(FlowSnapshotHint)把当前这一张的来源讲明,不让用户误以为它是实时图。 * * 本组件只做 store 接线;有图 / 无图两支的呈现交给 `FlowBody`(props 驱动), - * 使那条判定能被真实渲染测试钉住(见 flowSnapshot.render.test.ts 头注释)。 */ + * 使那条判定能被真实渲染测试钉住(见 flowSnapshot.render.test.ts 头注释)。 + * + * **视图模式**(切片 W2;design/19 §7):① 「最近一轮」(既有,含泳道卡与历史轮回放); + * ② 「时间轴」(新,保留期内的多轮按时间串起来,每回合一个可折叠块)。时间轴的数据来自 + * W1 的新出口 `GET /api/v1/admin/flow/timeline`,**只在该视图下才取数**(不打扰后端), + * 且**不写 store**(只读回看,绝不顶掉正在跑的那一轮的实时图)。 */ export function FlowModule(): React.ReactNode { const sessions = useAppStore((s) => s.sessions); const flowGraphs = useAppStore((s) => s.flowGraphs); const flowLanes = useAppStore((s) => s.flowLanes); // 选择器选中值:默认主会话 `web`(后端固定 MainSessionID),不随激活会话漂移。 const [pickedSessionId, setPickedSessionId] = useState(MAIN_SESSION_ID); + // 视图模式 + 时间轴的展示顺序 / 折叠记忆(由本组件持有,呈现层保持纯 props 驱动)。 + const [view, setView] = useState("latest"); + const [order, setOrder] = useState("newest-first"); + const [expanded, setExpanded] = useState>({}); // 取数接线(纯函数,flowSession.ts):用哪个 sessionId 取数 / 画哪张图 / 本轮有哪些路 / 选项。 const { sessionId, graph, lanes, options } = flowPageData( sessions, @@ -54,6 +80,24 @@ export function FlowModule(): React.ReactNode { // 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**, // 因此翻历史绝不顶掉当前会话的实时图(裁决 16.4.2)。 const history = useFlowTurns(sessionId); + // 时间轴(W1 的新出口):**只在时间轴视图下取数**,同样不写 store。 + const timeline = useFlowTimeline(sessionId, view === "timeline"); + const newestTurnId = timeline.rounds.length > 0 ? timeline.rounds[0].turnId : ""; + const onToggleRound = useCallback( + (turnId: string) => setExpanded((m) => toggleRoundExpansion(m, turnId, turnId === newestTurnId)), + [newestTurnId], + ); + const onToggleOrder = useCallback( + () => setOrder((o) => (o === "newest-first" ? "oldest-first" : "newest-first")), + [], + ); + const binding: TimelineBinding = { + order, + onToggleOrder, + expanded, + onToggleRound, + onReload: timeline.reload, + }; return ( ); } @@ -83,27 +131,81 @@ export function FlowModule(): React.ReactNode { * 泳道卡(并行路各自成图、互不覆盖);单路 / 未给 `lanes` 时渲染路径与引入本键之前 * 逐字相同(既有渲染断言因此不受影响)。 */ export function FlowBody({ - graph, - lanes, - sessionId, - source, sessionOptions, onSelectSession, - turns = [], - selectedTurnId = LATEST_TURN, - onSelectTurn, - historyGraph = null, - historyLoading = false, -}: { + view = "latest", + onSelectView, + timeline = null, + timelineBinding, + ...latest +}: FlowBodyProps): React.ReactNode { + // 屏幕上该画哪张图(纯函数,见 flowHistory.ts):选历史轮画落盘那张,否则画实时/快照那张。 + // 只用于工具栏里的「历史轮说明条」;内容区由 `LatestRound` 自己再算一遍同一支纯函数。 + const shown = resolveShownGraph( + latest.selectedTurnId ?? LATEST_TURN, + latest.graph, + latest.historyGraph ?? null + ); + const showingHistory = (latest.selectedTurnId ?? LATEST_TURN) !== LATEST_TURN; + return ( +
+ + + {latest.onSelectTurn || onSelectSession || onSelectView ? ( + + {onSelectView ? : null} + {onSelectSession ? ( + + ) : null} + {/* 历史轮选择器只在「最近一轮」视图下渲染 —— 时间轴自己有分页口径, + 两套轮次控制同时在场只会让人分不清「翻的是哪一套」。 */} + {view === "latest" && latest.onSelectTurn ? ( + + ) : null} + {view === "latest" && showingHistory && shown ? ( + + ) : null} + + ) : null} + + {view === "timeline" ? ( + timeline && timelineBinding ? ( + + ) : ( + + 时间轴取数未接线(本页只喂了「最近一轮」的数据)—— 请切回上方「最近一轮」视图。 + + ) + ) : ( + + )} +
+ ); +} + +/** 「最近一轮」视图的**全部** props(视图切换与时间轴之外的一切)。 */ +export interface FlowLatestProps { graph: FlowGraph | null; /** 本轮每一路的图(最旧的在前);省略时按单路处理(只用 `graph`)。 */ lanes?: readonly FlowGraph[]; sessionId: string; source: FlowGraphSource; - /** 会话选择器选项(只含白名单内会话)。 */ - sessionOptions?: readonly FlowSessionOption[]; - /** 选中会话的回调;不给则不渲染会话选择器(渲染测试因此不必造它)。 */ - onSelectSession?: (sessionId: string) => void; /** 该会话已落盘的轮次(最新在前)。 */ turns?: readonly AdminFlowTurn[]; /** 当前选择(`LATEST_TURN` = 最新一轮)。 */ @@ -114,7 +216,77 @@ export function FlowBody({ historyGraph?: FlowGraph | null; /** 正在拉取选中的历史轮。 */ historyLoading?: boolean; +} + +/** `FlowBody` 的全部 props = 最近一轮那一支 + 视图切换 / 时间轴接线。 */ +export interface FlowBodyProps extends FlowLatestProps { + /** 会话选择器选项(只含白名单内会话)。 */ + sessionOptions?: readonly FlowSessionOption[]; + /** 选中会话的回调;不给则不渲染会话选择器(渲染测试因此不必造它)。 */ + onSelectSession?: (sessionId: string) => void; + /** 视图模式(省略 = `latest`,与引入本键之前逐字相同)。 */ + view?: FlowView; + /** 切视图的回调;不给则不渲染视图页签(渲染测试因此不必造它)。 */ + onSelectView?: (view: FlowView) => void; + /** 时间轴取数状态(只在 `view="timeline"` 时被读)。 */ + timeline?: FlowTimelineState | null; + /** 时间轴的交互接线(顺序 / 折叠 / 重试)。 */ + timelineBinding?: TimelineBinding; +} + +/** 视图切换页签(`design/19 §7.2`:两个模式**可切**,不替换旧的)。 */ +function FlowViewTabs({ + view, + onSelect, +}: { + view: FlowView; + onSelect: (view: FlowView) => void; }): React.ReactNode { + return ( +
+ 视图 + {VIEW_TABS.map((tab) => ( + + ))} + + {VIEW_TABS.find((t) => t.id === view)?.hint ?? ""} + +
+ ); +} + +/** 「最近一轮」视图的内容区(既有行为,逐字保留):有图就画整页、没图就诚实空态。 + * + * **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画 + * 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。 + * + * **多路(切片 FV2)**:`lanes` 是选中会话本轮**每一路**的图。≥2 路时每条路各占一条 + * 泳道卡(并行路各自成图、互不覆盖);单路 / 未给 `lanes` 时渲染路径与引入本键之前 + * 逐字相同(既有渲染断言因此不受影响)。 */ +export function LatestRound({ + graph, + lanes, + sessionId, + source, + turns = [], + selectedTurnId = LATEST_TURN, + onSelectTurn, + historyGraph = null, + historyLoading = false, +}: FlowLatestProps): React.ReactNode { // 屏幕上该画哪张图(纯函数,见 flowHistory.ts):选历史轮画落盘那张,否则画实时/快照那张。 const shown = resolveShownGraph(selectedTurnId, graph, historyGraph); const showingHistory = selectedTurnId !== LATEST_TURN; @@ -132,110 +304,84 @@ export function FlowBody({ const parallelLanes: readonly FlowGraph[] = !showingHistory && laneList.length > 1 ? laneList : []; + if (!shown) { + return ( + + {showingHistory + ? historyLoading + ? `正在读取历史轮 ${selectedTurnId} 的链路图……` + : `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。` + : "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"} + + ); + } + return ( -
- + <> + + } + label="会话" + value={{shown.sessionId ?? "web"}} + sub={shown.id} + tone="accent" + /> + } + label="节点" + value={shown.nodes.length} + sub={`${shown.edges.length} 条连线`} + tone="neutral" + /> + } + label="泳道" + value={laneCount > 0 ? laneCount : "—"} + sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"} + tone="accent" + /> + } + label="耗时" + value={fmtDuration(shown)} + sub={`开始 ${fmtStart(shown)}`} + tone="ok" + /> + - {onSelectTurn || onSelectSession ? ( - - {onSelectSession ? ( - - ) : null} - {onSelectTurn ? ( - - ) : null} - {showingHistory && shown ? : null} - - ) : null} - - {shown ? ( - <> - - } - label="会话" - value={{shown.sessionId ?? "web"}} - sub={shown.id} - tone="accent" - /> - } - label="节点" - value={shown.nodes.length} - sub={`${shown.edges.length} 条连线`} - tone="neutral" - /> - } - label="泳道" - value={laneCount > 0 ? laneCount : "—"} - sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"} - tone="accent" - /> - } - label="耗时" - value={fmtDuration(shown)} - sub={`开始 ${fmtStart(shown)}`} - tone="ok" - /> - - - {parallelLanes.length > 0 ? ( - 0 ? ( + + ) : ( + + + setSelectedId(n.id)} + /> + + {showingHistory ? null : ( + - ) : ( - - - setSelectedId(n.id)} - /> - - {showingHistory ? null : ( - - )} - )} - - {selected && parallelLanes.length === 0 ? ( - setSelectedId(null)} - /> - ) : null} - - ) : ( - - {showingHistory - ? historyLoading - ? `正在读取历史轮 ${selectedTurnId} 的链路图……` - : `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。` - : "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"} - + )} -
+ + {selected && parallelLanes.length === 0 ? ( + setSelectedId(null)} + /> + ) : null} + ); } diff --git a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx index c02adc2..0c8440d 100644 --- a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx +++ b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx @@ -1,26 +1,43 @@ import { flowSessionLabel } from "./flowSession"; -/** 链路图快照的诚实说明条(契约 v0.71 §4.11(b)-1)。 +/** 说明条要讲清的**图的来源**(三支互不冒充): + * - `live` = 本次页面存活期间**实时收到**的 `graph_update` 帧; + * - `snapshot` = 打开页面时**只读拉取**的「最近一轮」快照(`GET /admin/flow/graph`); + * - `timeline` = 时间轴视图里**某一回合**从落盘按时间轴读回的图 + * (`GET /admin/flow/timeline`,`design/19 §7`)—— 既不是实时帧,也不是「最近一轮」。 */ +export type FlowHintSource = "live" | "snapshot" | "timeline"; + +/** 链路图的**诚实说明条**(契约 v0.71 §4.11(b)-1;时间轴一支见 `design/19 §7.2`)。 * - * 为什么必须有它:后端只保留**每个会话最近一轮**的图,而补拉通路的用途正是 + * 为什么必须有它:后端的老出口只保留**每个会话最近一轮**的图,而补拉通路的用途正是 * 「轮次结束后才打开页面」。于是图上画的到底是哪一种来源必须讲清: * - `snapshot` = 打开页面时**只读拉取**的那一份最近一轮(不是实时帧,也**不一定 * 是你眼前这一行那一轮** —— 后台思考行尤其如此); - * - `live` = 这次页面存活期间**实时收到**的图(正在跑 / 刚跑完的那一轮)。 + * - `live` = 这次页面存活期间**实时收到**的图(正在跑 / 刚跑完的那一轮); + * - `timeline` = 时间轴上的**这一个回合**(从落盘读回,保留期内可读;多轮出口)。 * - * 不写这一条就等于让用户以为「这行思考有自己的图」,那是把推导当事实: - * 多轮历史回放仍未实现(`design/19 §4` P3)。 */ + * 不写这一条就等于让用户以为「这行思考有自己的图」,那是把推导当事实。 + */ export function FlowSnapshotHint({ sessionId, source, }: { sessionId: string; - source: "live" | "snapshot"; + source: FlowHintSource; }): React.ReactNode { const label = flowSessionLabel(sessionId); return (
- {source === "snapshot" ? ( + {source === "timeline" ? ( + <> + 以上是 {label}( + {sessionId})的 + 这一个回合 + 的链路图 —— 时间轴从本地落盘按时间读回( + GET /api/v1/admin/flow/timeline,只读)。保留期内的回合才在;已过保留期的 + 回合不在时间轴上(页首会标注)。它不是实时帧。 + + ) : source === "snapshot" ? ( <> 以上是 {label}( {sessionId})的 diff --git a/frontend/web/src/admin/modules/flow/LaneTracks.tsx b/frontend/web/src/admin/modules/flow/LaneTracks.tsx index e1f5c6e..c31cde4 100644 --- a/frontend/web/src/admin/modules/flow/LaneTracks.tsx +++ b/frontend/web/src/admin/modules/flow/LaneTracks.tsx @@ -4,8 +4,7 @@ import { Surface } from "../../../components/primitives"; import { FlowGraphView } from "../FlowGraph"; import { NodeDetail } from "./NodeDetail"; import { LaneSummary } from "./LaneSummary"; -import { FlowSnapshotHint } from "./FlowSnapshotHint"; -import type { FlowGraphSource } from "./useFlowSnapshot"; +import { FlowSnapshotHint, type FlowHintSource } from "./FlowSnapshotHint"; import { groupFlowLanes } from "./lanes"; /** 真并行(`chat.parallel`)的**多路泳道**呈现(切片 FV2;design/19 §2、design/15 §2.5.10-1)。 @@ -28,8 +27,8 @@ export function LaneTracks({ lanes: readonly FlowGraph[]; /** 归属会话(拼快照说明条用)。 */ sessionId: string; - /** 图的来源(实时 / 快照),用于说明条。 */ - source: FlowGraphSource; + /** 图的来源(实时 / 快照 / 时间轴),用于说明条。 */ + source: FlowHintSource; }): React.ReactNode { return (
@@ -51,7 +50,7 @@ function LaneTrack({ /** 展示用的路序号(1 基,最旧的为 1)。 */ index: number; sessionId: string; - source: FlowGraphSource; + source: FlowHintSource; }): React.ReactNode { const [selectedId, setSelectedId] = useState(null); const grouping = groupFlowLanes(lane); @@ -76,7 +75,7 @@ function LaneTrack({ onNodeClick={(n) => setSelectedId(n.id)} /> - + {selected ? ( setSelectedId(null)} /> ) : null} diff --git a/frontend/web/src/admin/modules/flow/TimelineView.tsx b/frontend/web/src/admin/modules/flow/TimelineView.tsx new file mode 100644 index 0000000..26bed4a --- /dev/null +++ b/frontend/web/src/admin/modules/flow/TimelineView.tsx @@ -0,0 +1,278 @@ +import type { AdminFlowTimelineRound } from "../../../lib/api"; +import type { FlowGraph } from "../../../lib/types"; +import { Surface, Tag } from "../../../components/primitives"; +import { LaneTracks } from "./LaneTracks"; +import type { FlowTimelineState } from "./useFlowTimeline"; +import type { FlowTimelineOrder, RoundStats } from "./flowTimeline"; +import { + RETENTION_TRUNCATED_TEXT, + fmtRoundTime, + orderRounds, + retentionText, + roundExpanded, + roundGraphs, + roundLabel, + roundStats, + roundStatus, + roundStatusLabel, + roundStatusTag, + shortTurnId, +} from "./flowTimeline"; + +/** + * **时间轴视图**(切片 W2;`design/19 §7`,主人 2026-10-06 明确要求): + * 「所有消息按时间顺序串起来的流程图,而不是只有最新一个来回」。 + * + * 形态(逐条对齐 `design/19 §7.2`): + * - **竖轴 = 时间**,默认**最新在上**(与既有列表口径一致,可切到最新在下); + * - 每个回合一个**可折叠块**,标题 = 时间 + `round` + `turnId` 短标识 + 状态; + * - 块内**复用 `LaneTracks`** 画该回合各路的**并列泳道**(真并行不止一张); + * - 🔴 **块与块之间不画因果连线**(不臆造关系):只用时间与序号分隔,页首明写这一点; + * - 过期轮次不在返回值里 ⇒ 后端给 `truncated` 时页首**如实标注**「更早的轮次已过保留期」。 + * + * 全部 **props 驱动**(不读 store、不取数):本项目没有 jsdom,渲染测试只能喂 props + * (见 `flowSnapshot.render.test.ts` 头注释的同一理由)。改一个块的展开态由父层持有 + * (`Flow.tsx` 的 `FlowTimelineBinding`),因此「折叠 / 展开」两支都能被渲染测试直接钉住。 + */ +export function FlowTimelineView({ + state, + sessionId, + binding, +}: { + /** 取数层状态(`useFlowTimeline`)。 */ + state: FlowTimelineState; + /** 归属会话(写给泳道卡上的来源说明条)。 */ + sessionId: string; + /** 交互接线(顺序 / 折叠)。渲染测试可直接喂一份静态的。 */ + binding: TimelineBinding; +}): React.ReactNode { + const shown = orderRounds(state.rounds, binding.order); + const newest = state.rounds.length > 0 ? state.rounds[0] : null; + return ( +
+ + {shown.length > 0 ? ( +
    + {shown.map((round, i) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} + +/** 时间轴视图的交互接线(`Flow.tsx` 持有状态;渲染测试可喂静态值)。 */ +export interface TimelineBinding { + order: FlowTimelineOrder; + onToggleOrder: () => void; + /** 用户点过的展开态(`turnId → 展开?`);没点过的按「最新那回合默认展开」算。 */ + expanded: Readonly>; + onToggleRound: (turnId: string) => void; + /** 重试首屏(失败 / 老后端修复后)。 */ + onReload: () => void; +} + +/** 块标题之外的页首:顺序、分页、保留期口径、以及「不臆造关系」的明示。 */ +function TimelineHeader({ + state, + binding, +}: { + state: FlowTimelineState; + binding: TimelineBinding; +}): React.ReactNode { + return ( + +
+ 时间轴 + 共 {state.total} 个回合 + + {state.canLoadMore ? ( + + ) : state.total > 0 ? ( + 没有更早的回合了 + ) : null} + {state.moreFailed ? ( + 更早的回合这次没读到(已拿到的照旧显示) + ) : null} +
+

+ 时间轴只按时间与序号排列:块与块之间没有因果连线(不臆造关系)—— + 「上一块刚结束、下一块接着开始」只是时间相邻,不等于谁引发了谁。 +

+

+ {retentionText(state.retentionHours)} + {state.truncated ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null} +  · 数据源:GET /api/v1/admin/flow/timeline(只读回看,不写库、不发布帧) +

+
+ ); +} + +/** 没有回合时的**诚实空态**:老后端 / 读取失败 / 确实为空,三件事**分开说**。 */ +function TimelineEmpty({ + state, + onReload, +}: { + state: FlowTimelineState; + onReload: () => void; +}): React.ReactNode { + return ( +
+ {state.unsupported ? ( +

+ 当前只支持最近一轮 —— 本后端没有链路时间轴出口 + (GET /api/v1/admin/flow/timeline 未接线)。请切回上方的「最近一轮」视图看 + 最新那一轮;时间轴需要后端升级(老后端**不是**「这段时间没有回合」)。 +

+ ) : state.failed ? ( +

+ 链路时间轴暂时读不到(请求失败)—— 这不等于「这段时间没有回合」。 + +

+ ) : state.loading ? ( +

正在读取链路时间轴……

+ ) : ( +

+ 保留期内没有可回看的回合 —— 可能是该会话还没跑过带 span 的轮次,或( + {retentionText(state.retentionHours)})之外的都已被回收。发布会话白名单之外的会话 + (群聊 / 陌生人的 sess-*)从不产出链路图,那不是缺陷。 +

+ )} +
+ ); +} + +/** **一个回合块**:标题(时间 · 状态 · 轮次 · 回合标识 · 观测)+ 折叠的块内泳道。 + * + * 标题里每一件都是**可查的事实**:时间是后端给的开始时间(不可解析时是 `—`)、轮次号 + * 缺席就是「轮次未知」、回合标识来自后端(短标识只做截断展示,完整值在 `title` 里)。 + * 块内用 `LaneTracks` 画该回合**每一路**的图(复用,不另起一套渲染)。 */ +export function TimelineRoundBlock({ + round, + sessionId, + expanded, + newest, + onToggle, +}: { + round: AdminFlowTimelineRound; + sessionId: string; + expanded: boolean; + /** 是不是**最新**那一回合(默认展开、并标注)。 */ + newest: boolean; + onToggle: (turnId: string) => void; +}): React.ReactNode { + const stats = roundStats(round); + const status = roundStatus(round); + const graphs = roundGraphs(round); + return ( +
  • + {/* 竖轴(时间轴本体):一条中性竖线与一个锚点,**不指向**下一块 —— 只表示时间。 */} +
  • + ); +} + +/** 展开态下的块内:该回合**每一路**的并列泳道(复用 `LaneTracks`)+ 诚实缺席提示。 */ +function RoundBody({ + round, + sessionId, + stats, + graphs, +}: { + round: AdminFlowTimelineRound; + sessionId: string; + stats: RoundStats; + graphs: readonly FlowGraph[]; +}): React.ReactNode { + if (graphs.length === 0) { + return ( +

    + 这一回合没有可画的图(后端给的是空(graph:null)—— 可能是该轮没有跑 + span,或已过保留期)。 +

    + ); + } + return ( + <> + + {stats.missing > 0 ? ( +

    + 这一回合后端报了 {stats.legs} 路,其中 {stats.missing} 路没有留下图(如实缺席,不补画)。 +

    + ) : null} + + ); +} + +/** React key:优先用后端给的回合标识;标识缺席时退到序号(只为渲染,**不写进界面**)。 */ +function keyOf(round: AdminFlowTimelineRound, index: number): string { + return round.turnId || `#${index}`; +} diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts new file mode 100644 index 0000000..a9b8fc4 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts @@ -0,0 +1,368 @@ +// 「链路时间轴视图」的**真实渲染**测试(切片 W2;`design/19 §7`)。 +// +// 为什么需要它:主人 2026-10-06 要的是**看得见**的东西 ——「所有消息按时间顺序串起来的 +// 流程图,而不是只有最新一个来回」。逻辑测试(`flowTimeline.test.ts`)钉住排序 / 游标 / +// 状态判定;这里钉住**界面真的按它渲染**,且四条不美化: +// ① **顺序** = 真实时间序(默认最新在上,可切); +// ② **块与块之间不画任何东西**(不臆造因果)—— 从 SSR 出来的 HTML 里量两个块之间的 +// 字节间隔,只允许是结构性标签; +// ③ **过期不许静默**(`truncated` ⇒ 「更早的轮次已过保留期」); +// ④ **老后端 ≠ 没有回合**(404 ⇒ 「当前只支持最近一轮」,不是空态文案)。 +// +// 本项目没有 jsdom ⇒ 渲染 **props 驱动**的组件(同 `flowHistory.render.test.ts`)。 +// +// 跑法(渲染类测试必须 `--format=cjs`): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimeline.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-flow-timeline-render.test.cjs \ +// && node /tmp/cyrene-flow-timeline-render.test.cjs + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AdminFlowTimelineRound } from "../../../lib/api"; +import type { FlowGraph } from "../../../lib/types"; +import { MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { FlowBody } from "../Flow"; +import { FlowSnapshotHint } from "./FlowSnapshotHint"; +import { FlowTimelineView, TimelineRoundBlock, type TimelineBinding } from "./TimelineView"; +import { fmtRoundTime } from "./flowTimeline"; +import type { FlowTimelineState } from "./useFlowTimeline"; + +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)}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** 一路的图:`哨兵` 节点用来在页面上认这一路(label 是断言用的哨兵)。 */ +function laneGraph(sentinel: string, statuses: ReadonlyArray<"running" | "done"> = ["done"]): FlowGraph { + return { + id: `g-${sentinel}`, + sessionId: MAIN_SESSION_ID, + nodes: [ + { id: "input", label: `${sentinel}输入`, kind: "input", status: "done" }, + ...statuses.map((s, i) => ({ id: `n${i}`, label: `${sentinel}步骤${i + 1}`, kind: "tool", status: s })), + { id: "output", label: `${sentinel}回复`, kind: "output", status: "done" }, + ], + edges: [{ from: "input", to: "output" }], + }; +} + +/** 一个回合(`lanes[].graph` 为 `null` = 那一路没有留下图)。 */ +function round( + turnId: string, + startedAt: string, + opts: { + round?: number | null; + endedAt?: string | null; + lanes?: ReadonlyArray; + } = {} +): AdminFlowTimelineRound { + const lanes = opts.lanes ?? [laneGraph(turnId)]; + return { + turnId, + round: opts.round === undefined ? 1 : opts.round, + startedAt, + endedAt: opts.endedAt === undefined ? "2026-10-06T10:00:30+08:00" : opts.endedAt, + lanes: lanes.map((g, i) => ({ turnId: `${turnId}#leg${i + 1}`, graph: g })), + }; +} + +const A = round("web-1#turn-a", "2026-10-06T10:00:00+08:00", { round: 1, lanes: [laneGraph("哨兵甲")] }); +const B = round("web-2#turn-b", "2026-10-06T10:05:00+08:00", { round: 2, lanes: [laneGraph("哨兵乙")] }); +const C = round("web-3#turn-c", "2026-10-06T10:09:00+08:00", { + round: 3, + endedAt: null, + lanes: [laneGraph("哨兵丙"), laneGraph("哨兵丙2", ["running", "done"])], +}); +/** 钩子归一的顺序:**最新在前**(`normalizeTimelineRounds` 的口径,逻辑测试已钉)。 */ +const NEWEST_FIRST = [C, B, A]; + +function tlState( + rounds: AdminFlowTimelineRound[], + over: Partial = {} +): FlowTimelineState { + return { + rounds, + total: rounds.length, + retentionHours: 168, + truncated: false, + loading: false, + canLoadMore: false, + loadingMore: false, + unsupported: false, + failed: false, + moreFailed: false, + loadMore: () => {}, + reload: () => {}, + ...over, + }; +} + +function binding(over: Partial = {}): TimelineBinding { + return { + order: "newest-first", + onToggleOrder: () => {}, + expanded: {}, + onToggleRound: () => {}, + onReload: () => {}, + ...over, + }; +} + +function view( + state: FlowTimelineState, + over: Partial = {}, + sessionId = MAIN_SESSION_ID +): string { + return renderToStaticMarkup( + createElement(FlowTimelineView, { state, sessionId, binding: binding(over) }) + ); +} + +/** SSR 里每个回合块的区间(`
  • …
  • `)。 */ +interface Block { + turnId: string; + start: number; + end: number; + html: string; +} + +function blocks(markup: string): Block[] { + const out: Block[] = []; + const re = /]*data-flow-timeline-round="([^"]*)"[^>]*>([\s\S]*?)<\/li>/g; + for (let m = re.exec(markup); m !== null; m = re.exec(markup)) { + out.push({ turnId: m[1], start: m.index, end: m.index + m[0].length, html: m[2] }); + } + return out; +} + +// ── ① 默认视图:三个回合块,**最新在上**,页首讲清口径 ─────────────────────── +const base = view(tlState(NEWEST_FIRST)); +const bs = blocks(base); +same(bs.length, 3, "三个回合 ⇒ 三个块(不吞不造)"); +same(bs[0].turnId, C.turnId, "第一个块是最新那一回合(默认最新在上)"); +same(bs[2].turnId, A.turnId, "最后一个块是最旧那一回合"); +ok( + base.indexOf(`data-flow-timeline-round="${C.turnId}"`) < + base.indexOf(`data-flow-timeline-round="${A.turnId}"`), + "块在页面上的先后 = 时间倒序(最新在上)" +); +ok(has(base, "时间轴"), "页首标明这是时间轴视图"); +ok(has(base, "共 3 个回合"), "页首如实给出回合数"); +ok(has(base, "保留期 168 小时"), "页首如实给出保留期(后端给的值)"); +ok(has(base, "没有因果连线") && has(base, "不臆造关系"), "页首必须明写「块之间没有因果连线」"); +ok(has(base, "admin/flow/timeline"), "页首标明数据源端点"); + +// ── ② 块标题 = 时间 + 轮次 + 回合标识(短) + 状态 ─────────────────────────── +ok(has(bs[0].html, fmtRoundTime(C.startedAt)), "块标题带该回合的本地开始时间"); +ok(has(bs[0].html, "第 3 轮"), "块标题带轮次号"); +ok(has(bs[0].html, "turn-c"), "块标题带回合标识的**短**标识(取 # 之后那段)"); +ok(has(bs[0].html, `title="${C.turnId}"`), "完整回合标识在 title 里可查(短标识只是展示截断)"); +ok(!has(bs[0].html, "web-3#turn-c"), "页面上不铺完整回合标识(短标识 + title 才是口径)"); +ok(has(bs[0].html, "运行中"), "有一路还在跑 ⇒ 状态「运行中」(并行下不许说「完成」)"); +ok(has(bs[1].html, "完成"), "已收尾且节点都完成 ⇒ 「完成」"); +ok(has(bs[0].html, "2 路 · "), "块标题如实给出路数与节点数"); +ok(has(bs[0].html, "最新"), "最新那一回合被标出来(便于定位)"); + +// 「未收尾」是独立的一态:节点都跑完、但该回合没有收尾时间 ⇒ 不许写成「完成」。 +const notClosed = view( + tlState([round("web-9#turn-n", "2026-10-06T11:30:00+08:00", { endedAt: null })]) +); +ok(has(notClosed, "未收尾"), "没有收尾时间 ⇒ 状态「未收尾」(不是「完成」,也不是「运行中」)"); + +// ── ③ 折叠:最新默认展开(块内画该回合各路的并列泳道),其余折叠 ──────────── +ok(has(bs[0].html, "哨兵丙") && has(bs[0].html, "哨兵丙2"), "最新那回合默认展开 ⇒ 两路的图都画出来"); +ok(has(bs[0].html, "路 1") && has(bs[0].html, "路 2"), "块内复用并列泳道(每路一张卡)"); +ok(has(bs[0].html, "admin/flow/timeline"), "块内的来源说明条指向时间轴端点(不是「最近一轮」)"); +ok(!has(bs[1].html, "哨兵乙"), "更早的回合默认折叠 ⇒ 不画它的图"); +ok(has(bs[1].html, "已折叠"), "折叠态如实说明(不是空白)"); +ok(!has(base, "实时图"), "时间轴上的图绝不写成实时图"); + +// 点开更早的那一块 ⇒ 它的图出现(折叠记忆按 turnId 生效)。 +const opened = view(tlState(NEWEST_FIRST), { expanded: { [B.turnId]: true } }); +const openedB = blocks(opened)[1]; +ok(has(openedB.html, "哨兵乙"), "用户点开的那一块 ⇒ 画出它自己的图"); +ok(has(blocks(opened)[0].html, "哨兵丙"), "点开一块不影响最新那块的展开态"); + +// 把最新那一块点收起 ⇒ 它不再画图(用户的选择优先)。 +const collapsedNewest = view(tlState(NEWEST_FIRST), { expanded: { [C.turnId]: false } }); +ok(!has(blocks(collapsedNewest)[0].html, "哨兵丙"), "最新那一块用户点收起 ⇒ 保持收起"); +ok(has(blocks(collapsedNewest)[0].html, "展开"), "收起后按钮文案变成「展开」"); + +// ── ④ 顺序可切:最新在下 ──────────────────────────────────────────────────── +const ascending = view(tlState(NEWEST_FIRST), { order: "oldest-first" }); +same(blocks(ascending)[0].turnId, A.turnId, "切到「最新在下」⇒ 最旧在上"); +same(blocks(ascending)[2].turnId, C.turnId, "切顺序后最新在最后"); +ok(has(ascending, "最新在下"), "按钮文案反映当前顺序"); + +// ── ⑤ 🔴 块与块之间不画任何东西(不臆造关系) ──────────────────────────────── +for (let i = 1; i < bs.length; i += 1) { + const gap = base.slice(bs[i - 1].end, bs[i].start).replace(//g, "").trim(); + same(gap, "", `块 ${i} 与块 ${i + 1} 之间一个字节都没有(不画任何连线/箭头)—— 实际:「${gap}」`); +} +// 列表首尾同样不许有东西:整条时间轴**只有**这些块,块外没有任何连接元素。 +const head = base.slice(base.indexOf('data-flow-timeline="rounds"'), bs[0].start); +ok(!/]*data-flow-timeline-link/.test(base), "没有跨块的连接线元素"); +for (const b of bs) { + const others = bs.filter((o) => o.turnId !== b.turnId).map((o) => o.turnId); + ok( + others.every((id) => !has(b.html, id)), + `块「${b.turnId}」不引用别的回合的标识(不编造关系)` + ); +} + +// ── ⑥ 过期口径:`truncated` 必须显式标注,不能静默少一块 ──────────────────── +const truncated = view(tlState(NEWEST_FIRST, { truncated: true })); +ok(has(truncated, "更早的轮次已过保留期"), "有过期轮次 ⇒ 页首如实标注"); +ok(!has(base, "更早的轮次已过保留期"), "没有过期轮次 ⇒ 不写这句(不虚报)"); +ok(has(truncated, "共 3 个回合"), "截断只影响「还有更早的」提示,不影响已拿到的回合数"); + +// ── ⑦ 空态与降级:老后端 / 读取失败 / 确实为空,三件事分开说 ───────────────── +const unsupported = view(tlState([], { unsupported: true })); +ok(has(unsupported, "当前只支持最近一轮"), "老后端(404)⇒ 明确说「当前只支持最近一轮」"); +ok(has(unsupported, "admin/flow/timeline"), "老后端文案点名缺的是哪个出口"); +ok(!has(unsupported, "保留期内没有"), "老后端 ≠「没有回合」(两件事不许说成一件)"); +ok(!has(unsupported, "data-flow-timeline-round"), "老后端不画任何回合块"); + +const failed = view(tlState([], { failed: true })); +ok(has(failed, "暂时读不到"), "读取失败 ⇒ 如实说读不到"); +ok(has(failed, "重试"), "读取失败 ⇒ 给重试入口"); +ok(!has(failed, "保留期内没有"), "读取失败 ≠「没有回合」"); + +const empty = view(tlState([])); +ok(has(empty, "保留期内没有可回看的回合"), "确实为空 ⇒ 诚实空态"); +ok(has(empty, "保留期 168 小时"), "空态也要给出保留期口径(解释「为什么没有」)"); +ok(has(empty, "群聊"), "空态点明白名单外会话从不产出链路图(那不是缺陷)"); + +const loading = view(tlState([], { loading: true })); +ok(has(loading, "正在读取链路时间轴"), "首屏加载中 ⇒ 加载态(不是空态)"); + +// ── ⑧ 缺席与异常都不许美化 ───────────────────────────────────────────────── +const unknownRound = view(tlState([round("web-9#turn-x", "2026-10-06T11:00:00+08:00", { round: null })])); +ok(has(unknownRound, "轮次未知"), "轮次号缺席 ⇒ 「轮次未知」(不用 0 冒充)"); + +const noGraph = view( + tlState([round("web-9#turn-y", "2026-10-06T11:00:00+08:00", { lanes: [null, null] })]) +); +ok(has(noGraph, "无图"), "一路图都没有 ⇒ 状态「无图」(不是「空闲」)"); +ok(has(noGraph, "没有可画的图"), "没有图 ⇒ 块内如实说明,不补画"); + +const missingLeg = view( + tlState([round("web-9#turn-z", "2026-10-06T11:00:00+08:00", { lanes: [laneGraph("哨兵独"), null] })]) +); +ok(has(missingLeg, "其中 1 路没有留下图"), "有一路没有图 ⇒ 如实报出差额"); + +const noId = view(tlState([{ turnId: "", round: 4, startedAt: "2026-10-06T11:00:00+08:00", lanes: [] }])); +ok(has(noId, "无回合标识"), "回合标识缺席 ⇒ 如实说没有标识(绝不现编一个)"); + +// ── ⑨ 加载更早的回合:满页才给按钮,翻不动时如实收口 ─────────────────────── +const more = view(tlState(NEWEST_FIRST, { canLoadMore: true })); +ok(has(more, "加载更早的回合"), "还有更早的 ⇒ 给翻页入口"); +ok(!has(base, "加载更早的回合"), "没有更早的 ⇒ 不给翻页入口"); +ok(has(base, "没有更早的回合了"), "没有更早的 ⇒ 如实说没有(不是静默消失)"); +const moreFailed = view(tlState(NEWEST_FIRST, { canLoadMore: true, moreFailed: true })); +ok(has(moreFailed, "已拿到的照旧显示"), "翻页失败只影响翻页,不把已拿到的回合收走"); + +// ── ⑩ 块内来源说明条:时间轴的图**不是**「最近一轮」快照 ───────────────────── +const hint = renderToStaticMarkup( + createElement(FlowSnapshotHint, { sessionId: MAIN_SESSION_ID, source: "timeline" }) +); +ok(has(hint, "这一个回合"), "时间轴说明条讲的是「这一个回合」"); +ok(has(hint, "admin/flow/timeline"), "时间轴说明条指向时间轴端点"); +ok(!has(hint, "最近一轮"), "时间轴说明条不得写成「最近一轮」(两种来源不许混)"); +ok(has(hint, "本地落盘"), "时间轴说明条写明来自本地落盘(保留期内可读)"); + +// ── ⑪ 单块组件:喂 props 也能独立钉住「展开 / 折叠」两支 ──────────────────── +const oneBlock = renderToStaticMarkup( + createElement(TimelineRoundBlock, { + round: B, + sessionId: MAIN_SESSION_ID, + expanded: true, + newest: false, + onToggle: () => {}, + }) +); +ok(has(oneBlock, "哨兵乙") && has(oneBlock, "第 2 轮"), "单块展开态:图与标题都在"); +const oneCollapsed = renderToStaticMarkup( + createElement(TimelineRoundBlock, { + round: B, + sessionId: MAIN_SESSION_ID, + expanded: false, + newest: false, + onToggle: () => {}, + }) +); +ok(!has(oneCollapsed, "哨兵乙") && has(oneCollapsed, "第 2 轮"), "单块折叠态:只留标题(标题仍是可查的事实)"); + +// ── ⑫ FlowBody 接线:视图可切,且「最近一轮」那一支逐字不变 ───────────────── +const latestGraph: FlowGraph = { + id: "web-77", + sessionId: MAIN_SESSION_ID, + nodes: [{ id: "input", label: "最新哨兵", kind: "input", status: "done" }], + edges: [], +}; +const bodyTimeline = renderToStaticMarkup( + createElement(FlowBody, { + graph: latestGraph, + sessionId: MAIN_SESSION_ID, + source: "live", + view: "timeline", + onSelectView: () => {}, + timeline: tlState(NEWEST_FIRST), + timelineBinding: binding(), + }) +); +ok(has(bodyTimeline, "时间轴") && has(bodyTimeline, "共 3 个回合"), "FlowBody 在时间轴视图下画时间轴"); +ok(has(bodyTimeline, "视图"), "给了 onSelectView ⇒ 渲染视图切换页签"); +ok(!has(bodyTimeline, "最新哨兵"), "时间轴视图不画「最近一轮」的图(两套不混)"); +ok(!has(bodyTimeline, 'id="flow-turn-select"'), "时间轴视图不渲染历史轮选择器(避免两套轮次控制)"); + +const bodyLatest = renderToStaticMarkup( + createElement(FlowBody, { + graph: latestGraph, + sessionId: MAIN_SESSION_ID, + source: "live", + view: "latest", + onSelectView: () => {}, + timeline: tlState(NEWEST_FIRST), + timelineBinding: binding(), + }) +); +ok(has(bodyLatest, "最新哨兵"), "「最近一轮」视图照旧画实时/快照图(回归)"); +ok(!has(bodyLatest, "共 3 个回合"), "「最近一轮」视图不掺时间轴的内容"); + +const bodyPlain = renderToStaticMarkup( + createElement(FlowBody, { graph: latestGraph, sessionId: MAIN_SESSION_ID, source: "live" }) +); +ok(!has(bodyPlain, ">视图<"), "没给 onSelectView ⇒ 不渲染页签(既有渲染调用形态零影响)"); +ok(has(bodyPlain, "最新哨兵"), "默认视图仍是「最近一轮」"); + +const bodyUnwired = renderToStaticMarkup( + createElement(FlowBody, { + graph: latestGraph, + sessionId: MAIN_SESSION_ID, + source: "live", + view: "timeline", + onSelectView: () => {}, + }) +); +ok(has(bodyUnwired, "时间轴取数未接线"), "切到时间轴但没喂数据 ⇒ 如实说未接线(不假装空态)"); + +console.log(`flow timeline render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts new file mode 100644 index 0000000..15aff27 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts @@ -0,0 +1,336 @@ +// 「链路时间轴」的**纯逻辑**测试(切片 W2;`design/19 §7`;W1 的新出口)。 +// +// 为什么需要它:时间轴的四条硬口径全是**判定**而非渲染 —— ① 顺序与分页游标(`before` +// 恒向更早走,与展示顺序无关);② **不臆造关系**(去重只认后端给的回合标识,标识缺席 +// 也不自己拼一个);③ 状态与文案必须把「无图 / 未收尾 / 完成」分开说;④ 老后端(404) +// 与「没有回合」必须分成两件事。本项目没有前端测试运行器,因此这是无依赖的纯 node 检查 +// (与 `flowHistory.test.ts` / `flowSession.test.ts` 同一手法)。 +// +// 跑法: +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimeline.test.ts \ +// --bundle --platform=node --format=esm \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-flow-timeline.test.mjs \ +// && node /tmp/cyrene-flow-timeline.test.mjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。 + +import { ApiError, adminFlowTimeline } from "../../../lib/api"; +import type { AdminFlowTimelineRound } from "../../../lib/api"; +import type { FlowGraph } from "../../../lib/types"; +import { MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { + RETENTION_TRUNCATED_TEXT, + TIMELINE_PAGE_SIZE, + appendTimelinePage, + classifyTimelineFailure, + mergeTimelineRounds, + normalizeTimelineRounds, + orderRounds, + pageMayHaveOlder, + retentionText, + roundExpanded, + roundGraphs, + roundLabel, + roundStats, + roundStatus, + roundStatusLabel, + roundStatusTag, + shortTurnId, + timelineCursor, + timelinePageOf, + toggleRoundExpansion, +} from "./flowTimeline"; + +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)}`); +} + +/** 一路的图:一个 `input` 节点 + 若干指定状态的节点(哨兵用 label)。 */ +function graph(id: string, statuses: ReadonlyArray<"running" | "done" | "error" | "blocked"> = ["done"]): FlowGraph { + return { + id, + sessionId: MAIN_SESSION_ID, + nodes: [ + { id: "input", label: "输入", kind: "input", status: "done" }, + ...statuses.map((s, i) => ({ id: `n${i}`, label: `节点${i}`, kind: "tool", status: s })), + ], + edges: [{ from: "input", to: "n0" }], + }; +} + +/** 一个回合:`startedAt` 决定它在时间轴上的位置。 */ +function round( + turnId: string, + startedAt: string, + opts: { round?: number | null; endedAt?: string | null; graphs?: ReadonlyArray } = {} +): AdminFlowTimelineRound { + const graphs = opts.graphs ?? [graph(`${turnId}-1`)]; + return { + turnId, + round: opts.round === undefined ? 1 : opts.round, + startedAt, + endedAt: opts.endedAt === undefined ? "2026-10-06T10:00:10+08:00" : opts.endedAt, + lanes: graphs.map((g, i) => ({ turnId: `${turnId}#leg${i + 1}`, graph: g })), + }; +} + +// ── ① 归一:去重(只认后端给的标识)、按开始时间倒序 ───────────────────────── +const r1 = round("web-1#turn-a", "2026-10-06T10:00:00+08:00", { round: 1 }); +const r2 = round("web-2#turn-b", "2026-10-06T10:05:00+08:00", { round: 2 }); +const r3 = round("web-3#turn-c", "2026-10-06T10:09:00+08:00", { round: 3 }); +// 后端按**时间正序**返回(设计 §7.1),面板必须自己摆成「最新在前」。 +const normalized = normalizeTimelineRounds([r1, r2, r3]); +same(normalized.length, 3, "三个回合就是三条(不吞不造)"); +same(normalized[0].turnId, "web-3#turn-c", "归一后最新在前"); +same(normalized[2].turnId, "web-1#turn-a", "归一后最旧在最后"); +same( + normalizeTimelineRounds([r1, r1, r2]).length, + 2, + "同一个回合标识出现两次 ⇒ 只留一条(分页重叠不重)" +); +same(normalizeTimelineRounds(null).length, 0, "没有回合 ⇒ 空数组(不编造)"); +same(normalizeTimelineRounds(undefined).length, 0, "响应缺 rounds 键 ⇒ 空数组"); + +// 标识缺席:按「开始时间 + 轮次」去重;既无标识又无时间 ⇒ **不去重**(无法判定是同一回合)。 +const anonA: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] }; +const anonB: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] }; +same(normalizeTimelineRounds([anonA, anonB]).length, 1, "无标识但同时间同轮次 ⇒ 视作同一回合"); +const noTimeA: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "", lanes: [] }; +const noTimeB: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "坏时间", lanes: [] }; +same( + normalizeTimelineRounds([noTimeA, noTimeB]).length, + 2, + "既无标识又无可用时间 ⇒ 不去重(宁可多显示,也不吞掉一条)" +); + +// ── ② 展示顺序:可切,且**不影响分页游标** ─────────────────────────────────── +same(orderRounds(normalized, "newest-first")[0].turnId, "web-3#turn-c", "默认顺序 = 最新在上"); +same(orderRounds(normalized, "oldest-first")[0].turnId, "web-1#turn-a", "切顺序 ⇒ 最旧在上"); +same(timelineCursor(normalized), r1.startedAt, "游标 = 手上**最旧**那一回合的开始时间(原样回传)"); +same( + timelineCursor(orderRounds(normalized, "oldest-first")), + r1.startedAt, + "切展示顺序不改变游标(分页恒向更早走)" +); +same(timelineCursor([]), null, "没有回合 ⇒ 没有游标(不发请求)"); +same( + timelineCursor([noTimeA, noTimeB]), + null, + "最旧那一回合没有可用时间 ⇒ 游标为 null(绝不瞎凑一个游标)" +); +same( + timelineCursor([r1, { ...r2, startedAt: "不是时间" }]), + r1.startedAt, + "坏时间的那一条只被跳过,不吃掉能用的游标" +); + +// ── ③ 分页:满页才可能还有更早的 ──────────────────────────────────────────── +ok(pageMayHaveOlder(TIMELINE_PAGE_SIZE, TIMELINE_PAGE_SIZE), "满页 ⇒ 还可能更早"); +ok(!pageMayHaveOlder(3, TIMELINE_PAGE_SIZE), "短页 ⇒ 到头了(否则会在同一游标上打转)"); +ok(!pageMayHaveOlder(0, 0), "页大小非法 ⇒ 不宣称还有更早的"); + +const page1 = normalizeTimelineRounds([r2, r3]); +const merged = mergeTimelineRounds(page1, normalizeTimelineRounds([r1, r2])); +same(merged.length, 3, "翻页重叠 ⇒ 合并后不重(3 条不是 4 条)"); +same(merged.map((r) => r.turnId).join(","), "web-3#turn-c,web-2#turn-b,web-1#turn-a", "合并后仍是最新在前"); + +// 追加一页的判定:满页 + 真有新回合才继续翻。 +const r0 = round("web-0#turn-0", "2026-10-06T09:00:00+08:00", { round: 0 }); +const fullPage = [r1, r0]; +const appendFresh = appendTimelinePage([r2, r3], fullPage, 2); +same(appendFresh.added, 2, "追加一页:落在外面的都算新增"); +same(appendFresh.rounds.length, 4, "追加后 4 条(去重不重)"); +ok(appendFresh.canLoadMore, "满页 + 有新增 ⇒ 还能继续翻"); +const appendStale = appendTimelinePage([r1, r2], [r1, r2], 2); +same(appendStale.added, 0, "整页都是已有的回合 ⇒ 新增 0 条"); +ok(!appendStale.canLoadMore, "没有新增 ⇒ 停住(否则会在同一游标上打转)"); +const shortPage = appendTimelinePage([r2, r3], [r1], 2); +same(shortPage.rounds.length, 3, "短页里的新回合照旧并入(不因为停翻就丢掉已拿到的)"); +ok(!shortPage.canLoadMore, "短页(哪怕有新增)⇒ 仍到头了"); + +// ── ④ 短标识:只截断展示,不反解、不拼 id ──────────────────────────────────── +same(shortTurnId("web-7#turn-3f9c8ab1c2d3"), "turn-3f9c8ab…", "带 # 的标识取 # 之后那段并截断"); +same(shortTurnId("web-7"), "web-7", "老行没有 # ⇒ 原样(短就不用截断)"); +same(shortTurnId(""), "无回合标识", "标识缺席 ⇒ 如实说没有标识(绝不现编一个)"); + +// ── ⑤ 轮次号:缺席 / null ⇒ 「轮次未知」,不得用 0 冒充 ────────────────────── +same(roundLabel(3), "第 3 轮", "有轮次号就报号"); +same(roundLabel(0), "第 0 轮", "0 是后端真的给的号就照报(本切片不去猜它的含义)"); +same(roundLabel(undefined), "轮次未知", "轮次号缺席 ⇒ 「轮次未知」"); +same(roundLabel(null), "轮次未知", "轮次号为 null ⇒ 「轮次未知」"); + +// ── ⑥ 状态:运行 / 错误 / 阻塞优先;未收尾 ≠ 完成;无图 ≠ 空闲 ─────────────── +same(roundStatus(round("a", "t", { graphs: [graph("g1", ["running", "done"])] })), "running", "有在跑的节点 ⇒ 运行中"); +same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"]), graph("g2", ["error"])] })), "error", "一路出错 ⇒ 整回合错误(并行下不许说「完成」)"); +same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])] })), "done", "节点都完成且有收尾时间 ⇒ 完成"); +same( + roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: null })), + "unfinished", + "没有收尾时间 ⇒ 未收尾(不是「完成」,也不是「运行中」)" +); +same( + roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: "" })), + "unfinished", + "收尾时间为空串 ⇒ 同样按未收尾(不把空当收尾过)" +); +same( + roundStatus(round("a", "t", { graphs: [null, null] })), + "none", + "一路图都没有 ⇒ 「无图」(不是「空闲」)" +); +same(roundStatus(round("a", "t", { graphs: [] })), "none", "没有路 ⇒ 「无图」"); +same(roundStatus(null), "none", "没有这个回合 ⇒ 「无图」"); +same(roundStatusLabel("none"), "无图", "「无图」的文案"); +same(roundStatusLabel("unfinished"), "未收尾", "「未收尾」的文案"); +same(roundStatusLabel("running"), "运行中", "运行中文案与既有泳道口径一致"); +same(roundStatusTag("none"), "neutral", "「无图」不着色(不是告警)"); +same(roundStatusTag("unfinished"), "warn", "「未收尾」着警示色"); + +// ── ⑦ 观测量:路数 / 有图的路数 / 节点数 / 缺席的路数 ─────────────────────── +const mixed = round("a", "t", { graphs: [graph("g1", ["done", "done"]), null] }); +const stats = roundStats(mixed); +same(stats.legs, 2, "路数按后端报的算(含没图的)"); +same(stats.drawn, 1, "有图可画的只有一路"); +same(stats.missing, 1, "缺席的路数必须能让用户看见"); +same(stats.nodes, 3, "节点数按有图的那些算"); +same(roundGraphs(mixed).length, 1, "取图只取真有的(null 不进画布)"); +same(roundStats(null).legs, 0, "没有回合 ⇒ 全零(不编造)"); + +// ── ⑧ 折叠记忆:最新默认展开,用户点过以用户为准 ──────────────────────────── +ok(roundExpanded("web-3#turn-c", {}, true), "没人点过时:最新那回合默认展开"); +ok(!roundExpanded("web-1#turn-a", {}, false), "没人点过时:更早的回合默认折叠"); +const toggled = toggleRoundExpansion({}, "web-1#turn-a", false); +ok(toggled["web-1#turn-a"] === true, "点一下 ⇒ 展开(并按用户的选择记住)"); +ok( + roundExpanded("web-1#turn-a", toggled, true) === true, + "用户点开过的块保持开 —— 即使它成为「最新」也不被默认规则改写" +); +const newestCollapsed = toggleRoundExpansion({}, "web-3#turn-c", true); +ok( + roundExpanded("web-3#turn-c", newestCollapsed, true) === false, + "最新那回合被用户点收起 ⇒ 保持收起(用户的选择优先于「最新默认展开」)" +); +const toggledTwice = toggleRoundExpansion(toggled, "web-1#turn-a", false); +ok(toggledTwice["web-1#turn-a"] === false, "再点一下 ⇒ 收起"); +same(toggleRoundExpansion({ web: true }, "", false).web, true, "标识为空时不动别人的状态(不写空键)"); + +// ── ⑨ 保留期文案:三支互不冒充 ─────────────────────────────────────────────── +same(retentionText(168), "保留期 168 小时", "保留期如实报小时数"); +ok(retentionText(0).includes("未启用"), "≤0 = 后端配置为不回收(既有口径)"); +ok(retentionText(-1).includes("未启用"), "负值同样按「未启用」"); +ok(retentionText(null).includes("未知"), "缺失 ⇒ 未知(不编一个数)"); +ok(retentionText(undefined).includes("未知"), "undefined ⇒ 未知"); +ok(RETENTION_TRUNCATED_TEXT.includes("保留期"), "过期提示必须是「已过保留期」这件事(不是「没有回合」)"); + +// ── ⑩ 失败分类:老后端(没这个出口)≠ 这次没读到 ────────────────────────── +same(classifyTimelineFailure(new ApiError(404, "not_found", "no route")), "unsupported", "404 ⇒ 老后端,当前只支持最近一轮"); +same(classifyTimelineFailure(new ApiError(501, "not_implemented", "nope")), "unsupported", "501 同样算「没有这个出口」"); +same(classifyTimelineFailure(new ApiError(500, "flow_unavailable", "boom")), "failed", "500 是「这次没读到」"); +same(classifyTimelineFailure(new ApiError(0, "network_error", "offline")), "failed", "网络失败也是「这次没读到」"); +same(classifyTimelineFailure(new Error("boom")), "failed", "非 ApiError ⇒ 按失败处理(不声称老后端)"); + +// ── ⑪ 响应解包:缺键按诚实缺省 ────────────────────────────────────────────── +const packed = timelinePageOf({ + rounds: [r1], + retentionHours: 168, + truncated: true, +}); +same(packed.rounds.length, 1, "解包保留回合"); +same(packed.retentionHours, 168, "解包保留期"); +same(packed.truncated, true, "解包截断标记"); +const packedEmpty = timelinePageOf(null); +same(packedEmpty.rounds.length, 0, "没有响应 ⇒ 空回合"); +same(packedEmpty.retentionHours, null, "没有响应 ⇒ 保留期未知(不是 0)"); +same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「已过保留期」"); + +// ── ⑫ 真跑 api 客户端:URL 形状 + 游标**原样**回传(不换算、不拼 id) ─────── +type Call = { url: string; method: string }; +const calls: Call[] = []; +const originalFetch = globalThis.fetch; +const originalLocalStorage = globalThis.localStorage; + +/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */ +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +} as unknown as Storage; + +globalThis.fetch = ((url: string, init?: { method?: string }) => { + calls.push({ url: String(url), method: init?.method ?? "GET" }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ data: { rounds: [r1], retentionHours: 168, truncated: false } }), + } as unknown as Response); +}) as typeof globalThis.fetch; + +const resp = await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE }); +same(resp.rounds.length, 1, "真解析回包(`{data}` 信封)"); +ok( + calls[0].url.endsWith("/admin/flow/timeline?sessionId=web&limit=50"), + `请求形状:${calls[0].url}` +); +same(calls[0].method, "GET", "时间轴取数是只读 GET"); +await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE, before: r1.startedAt }); +ok( + decodeURIComponent(calls[1]?.url ?? "").includes(`before=${r1.startedAt}`), + `带游标时**原样**回传后端给的时间字符串(不换算成毫秒、不重排):${calls[1]?.url}` +); + +/** 一个**非 2xx** 的桩:`body = null` 表示正文不是 JSON(真后端 404 就是这种: + * `HTTP 404 / 404 page not found`,实测于 2026-10-06 的 :10942)。 */ +function stubError(status: number, body: unknown): void { + globalThis.fetch = ((url: string, init?: { method?: string }) => { + calls.push({ url: String(url), method: init?.method ?? "GET" }); + return Promise.resolve({ + ok: false, + status, + json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)), + } as unknown as Response); + }) as typeof globalThis.fetch; +} + +/** 真跑一遍「今天的老后端」那条路:路由不存在 ⇒ 404 + 非 JSON 正文。 */ +async function classifyRealError(status: number, body: unknown): Promise { + stubError(status, body); + try { + await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE }); + return "no-error"; + } catch (err: unknown) { + return classifyTimelineFailure(err); + } +} + +same( + await classifyRealError(404, null), + "unsupported", + "路由不存在(404 + `404 page not found` 非 JSON 正文)⇒ 落到「当前只支持最近一轮」那一支" +); +same( + await classifyRealError(404, { error: { code: "not_found", message: "no route" } }), + "unsupported", + "404 + 规范信封 ⇒ 同样按「没有这个出口」" +); +same( + await classifyRealError(500, { error: { code: "flow_unavailable", message: "boom" } }), + "failed", + "500 ⇒ 按「这次没读到」(不是老后端)" +); + +globalThis.fetch = originalFetch; +globalThis.localStorage = originalLocalStorage; + +console.log(`flow timeline logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.ts b/frontend/web/src/admin/modules/flow/flowTimeline.ts new file mode 100644 index 0000000..0f7ef60 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowTimeline.ts @@ -0,0 +1,305 @@ +import type { AdminFlowTimelineResponse, AdminFlowTimelineRound } from "../../../lib/api"; +import { isNotImplemented } from "../../../lib/api"; +import type { AgentStatus, FlowGraph } from "../../../lib/types"; +import { statusOf } from "./tone"; + +/** + * 链路**时间轴**视图的纯逻辑(切片 W2;`design/19 §7`)。 + * + * 主人 2026-10-06 原话:「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来的 + * 流程图**,而不是只有最新一个来回。」⇒ Flow 页新增「时间轴」模式:**竖轴 = 时间**, + * 每个回合一个可折叠块,块内仍是该回合各路的**并列泳道**(复用 `LaneTracks`)。 + * + * 🔴 **不臆造关系**(`design/19 §7.2`):块与块之间**不画因果连线**,只用**时间与序号** + * 分隔;本模块因此只做三件与「关系」无关的事 —— 排序、游标、状态。 + * + * 🔴 **不自己拼 id**(契约 v0.79 起 `graph.id` 含 `#`、单路也有后缀):回合标识、各路 + * 标识一律用后端给的值;`shortTurnId` 只做**截断展示**,不改写、不反解。 + * + * 从组件里抽出来(同 `flowHistory.ts` / `flowSession.ts` 的手法):本项目没有 jsdom, + * hook 与 effect 在测试里跑不了,而「排序 / 游标 / 状态 / 折叠默认值」全是纯函数,逻辑 + * 测试可以直接钉死。`tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`。 + */ + +/** 时间轴**分页大小**(契约 `design/19 §7.1`:缺省 50 / 后端硬上限 200)。 */ +export const TIMELINE_PAGE_SIZE = 50; + +/** 时间轴的**展示顺序**(`design/19 §7.2`:最新在上或在下**可切**)。 + * 默认 `newest-first`(最新在上)—— 与既有列表口径一致。 */ +export type FlowTimelineOrder = "newest-first" | "oldest-first"; + +/** 「更早的轮次已过保留期」——**逐字**文案(`design/19 §7.1`:过期轮次不在返回值里时, + * 必须由面板如实标注,**不许静默少一块**)。 */ +export const RETENTION_TRUNCATED_TEXT = "更早的轮次已过保留期"; + +/** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。 + * 与 `flowHistory.ts#timeOf` 同一口径 —— 两处都只服务排序。 */ +function timeOf(iso: string | null | undefined): number { + if (!iso) return 0; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +/** 归一 + 去重 + 排序:**按开始时间倒序**(最新在前 = 面板默认顺序)。 + * + * 去重键:有回合标识用它(后端给的**权威**标识);标识缺席(老行 / 旧后端)时退到 + * 「开始时间 + 轮次」;两者都没有时**不去重**(无法判定是不是同一回合,宁可多显示一条 + * 也不吞掉一条)—— 三种情形都**不自己拼 id**。 */ +export function normalizeTimelineRounds( + rounds: readonly AdminFlowTimelineRound[] | null | undefined +): AdminFlowTimelineRound[] { + if (!rounds || rounds.length === 0) return []; + const seen = new Set(); + const kept: AdminFlowTimelineRound[] = []; + rounds.forEach((r, i) => { + if (!r) return; + const key = dedupeKeyOf(r, i); + if (seen.has(key)) return; + seen.add(key); + kept.push(r); + }); + return kept.sort((a, b) => timeOf(b.startedAt) - timeOf(a.startedAt)); +} + +/** 合并两批(分页追加用):先拼再归一(去重 + 倒序),因此翻页**绝不重不漏**地叠加。 + * 它与 `normalizeTimelineRounds` 是同一套判据(**唯一真源**),不另写一份比较器。 */ +export function mergeTimelineRounds( + existing: readonly AdminFlowTimelineRound[], + incoming: readonly AdminFlowTimelineRound[] | null | undefined +): AdminFlowTimelineRound[] { + return normalizeTimelineRounds([...existing, ...(incoming ?? [])]); +} + +/** 去重键。有标识用标识;否则用「开始时间 + 轮次」;都缺时用序号(即**不去重**)。 */ +function dedupeKeyOf(r: AdminFlowTimelineRound, index: number): string { + if (r.turnId) return `t:${r.turnId}`; + if (timeOf(r.startedAt) > 0) return `s:${r.startedAt}|${r.round ?? ""}`; + return `x:${index}`; +} + +/** 展示顺序:把倒序(最新在前)的列表换成要求的顺序。**只影响呈现**, + * 不改变分页游标(游标恒取「手上最旧那一回合」,见 `timelineCursor`)。 */ +export function orderRounds( + rounds: readonly AdminFlowTimelineRound[], + order: FlowTimelineOrder +): AdminFlowTimelineRound[] { + return order === "oldest-first" ? [...rounds].reverse() : [...rounds]; +} + +/** 下一页的 `before` 游标 = **手上最旧那一回合的开始时间**(原样回传后端给的值, + * 不做字符串/毫秒换算 —— 换算规格未定,猜一个比不猜更坏)。 + * + * 独立于展示顺序:无论「最新在上」还是「最新在下」,分页恒向**更早**走。 + * 时间不可解析(后端没给 / 给坏了)⇒ `null`:**不能翻页**(面板据此停住并如实说明), + * 绝不用别的字段瞎凑一个游标。 */ +export function timelineCursor( + rounds: readonly AdminFlowTimelineRound[] +): string | null { + let oldest = 0; + let cursor: string | null = null; + for (const r of rounds) { + const t = timeOf(r.startedAt); + if (t <= 0) continue; + if (cursor === null || t < oldest) { + oldest = t; + cursor = r.startedAt; + } + } + return cursor; +} + +/** 后端这一页是否**可能还有更早的**:满页才可能还有(短页 = 到头了)。 + * 与 `Notifications.tsx` 的游标翻页同一口径(短页即止),避免在同一个游标上打转。 */ +export function pageMayHaveOlder(receivedCount: number, pageSize: number): boolean { + return pageSize > 0 && receivedCount >= pageSize; +} + +/** **追加一页**的判定(纯函数,取数层与逻辑测试共用): + * - `rounds` = 合并去重后的全量(`mergeTimelineRounds`,不重不漏); + * - `added` = 这一页真正**新增**了几条; + * - `canLoadMore` = 还能不能继续翻 —— 要求 ① **真有新回合**(否则游标没推进,再翻只会在 + * 同一页上打转)且 ② 后端这一页是**满页**(`pageMayHaveOlder`)。 */ +export function appendTimelinePage( + existing: readonly AdminFlowTimelineRound[], + incoming: readonly AdminFlowTimelineRound[] | null | undefined, + pageSize: number +): { rounds: AdminFlowTimelineRound[]; added: number; canLoadMore: boolean } { + const page = incoming ?? []; + const rounds = mergeTimelineRounds(existing, page); + const added = rounds.length - existing.length; + return { rounds, added, canLoadMore: added > 0 && pageMayHaveOlder(page.length, pageSize) }; +} + +/** 回合标识的**短标识**(块标题用):取最后一个 `#` 之后那一段(v0.79 起标识形如 + * `web-7#turn-3f9c…`,有意义的是后半段),再截断到 12 字符加省略号。 + * **只做展示截断**:不重写、不反解、不生成新 id;完整值请用 `title` 属性看。 */ +export function shortTurnId(turnId: string): string { + if (!turnId) return "无回合标识"; + const at = turnId.lastIndexOf("#"); + const tail = at >= 0 ? turnId.slice(at + 1) : turnId; + const head = tail || turnId; + return head.length > 12 ? `${head.slice(0, 12)}…` : head; +} + +/** 轮次号文案。**缺席 / `null` ⇒ 「轮次未知」**(`design/19 §6.5` D:不得用 0 冒充未知)。 */ +export function roundLabel(round: number | null | undefined): string { + return typeof round === "number" && Number.isFinite(round) ? `第 ${round} 轮` : "轮次未知"; +} + +/** 时间轴块标题上的**时间**:本地 `MM-DD HH:mm:ss`(跨天要看日期,秒用来分辨相邻回合)。 + * 时间不可解析 ⇒ `—`(不编造)。 */ +export function fmtRoundTime(iso: string | null | undefined): string { + const t = timeOf(iso); + if (t <= 0) return "—"; + const d = new Date(t); + const p = (n: number): string => String(n).padStart(2, "0"); + return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** 一个回合的状态 = 由它**各路图里的节点状态**推导(后端不给「回合状态」这个字段, + * 面板也不许编一个)。比 `AgentStatus` 多两态,因为有两件事真的不是「空闲」: + * - `unfinished`:图里没有未跑完的节点,但该回合**没有收尾时间**(`endedAt` 缺席)—— + * 它不是「完成」,也不是「运行中」(帧可能早就不发了,例如进程中断的那一轮); + * - `none`:这个回合**一路图都没有**(`lanes[].graph` 全为 null)—— 不是「空闲」。 */ +export type RoundStatus = AgentStatus | "unfinished" | "none"; + +/** 一回合的状态:**运行中 / 错误 / 阻塞**优先(有活跃或出错的节点就不许说「完成」), + * 其次才是「未收尾」与「完成」。判据逐条写在下面的顺序里,不猜。 */ +export function roundStatus( + round: AdminFlowTimelineRound | null | undefined +): RoundStatus { + const graphs = roundGraphs(round); + if (graphs.length === 0) return "none"; + const seen = new Set(); + for (const g of graphs) for (const n of g.nodes) seen.add(statusOf(n)); + if (seen.has("running")) return "running"; + if (seen.has("error")) return "error"; + if (seen.has("blocked")) return "blocked"; + if (!round?.endedAt) return "unfinished"; + if (seen.has("done")) return "done"; + return "idle"; +} + +/** 一回合的状态文案(`none` / `unfinished` 有各自的说法,**不混进** FLOW_TONE 的 + * 「空闲」「完成」——那会把两件不同的事说成同一件)。 */ +export function roundStatusLabel(status: RoundStatus): string { + if (status === "none") return "无图"; + if (status === "unfinished") return "未收尾"; + return ROUND_STATUS_LABEL[status]; +} + +/** `AgentStatus` 那几态的中文标签(与 `tone.ts#FLOW_TONE` 同字面,不另起一套说法)。 */ +const ROUND_STATUS_LABEL: Record = { + idle: "空闲", + running: "运行中", + blocked: "阻塞", + done: "完成", + error: "错误", +}; + +/** 状态 → Tag 色调(`none` / `unfinished` 各有色调;其余借 `tone.ts` 的口径)。 */ +export function roundStatusTag(status: RoundStatus): "accent" | "ok" | "warn" | "err" | "neutral" { + if (status === "none") return "neutral"; + if (status === "unfinished") return "warn"; + if (status === "running") return "accent"; + if (status === "done") return "ok"; + if (status === "error") return "err"; + if (status === "blocked") return "warn"; + return "neutral"; +} + +/** 该回合**有图**的那几条路(`graph: null` 的条目被丢掉 —— 它们没有图可画, + * 数量由 `roundStats` 如实报出,绝不补画)。 */ +export function roundGraphs( + round: AdminFlowTimelineRound | null | undefined +): FlowGraph[] { + const out: FlowGraph[] = []; + for (const lane of round?.lanes ?? []) { + if (lane?.graph) out.push(lane.graph); + } + return out; +} + +/** 一回合的观测量:`legs` = 后端报的路数(含没图的)、`drawn` = 真有图可画的、`nodes` = 图上节点合计。 + * 两者不同就必须让用户看见差在哪(「其中 N 路没有留下图」)。 */ +export interface RoundStats { + legs: number; + drawn: number; + nodes: number; + missing: number; +} + +/** 一回合的观测量(纯计算,不猜)。 */ +export function roundStats(round: AdminFlowTimelineRound | null | undefined): RoundStats { + const legs = round?.lanes?.length ?? 0; + const graphs = roundGraphs(round); + let nodes = 0; + for (const g of graphs) nodes += g.nodes.length; + return { legs, drawn: graphs.length, nodes, missing: Math.max(0, legs - graphs.length) }; +} + +/** 保留期文案(`design/19 §7.1`:保留期口径必须如实交给客户端)。三支互不冒充: + * `>0` ⇒ 小时数;`≤0` ⇒ 后端配置为**不回收**(`FLOW_GRAPH_RETENTION ≤ 0` 的既有口径); + * 缺失 / 非数 ⇒ **未知**(后端没给这个数,面板不编一个)。 */ +export function retentionText(hours: number | null | undefined): string { + if (typeof hours !== "number" || !Number.isFinite(hours)) { + return "保留期未知(后端未给出该数值)"; + } + if (hours <= 0) return "保留期未启用(后端配置为不回收过期轮次)"; + return `保留期 ${hours} 小时`; +} + +/** 时间轴取数失败的分类。**404/501 ≠ 没有数据**:老后端根本没有这个出口 ⇒ 面板要说 + * 「当前只支持最近一轮」,而不是空态(两件事必须分开说)。 */ +export type TimelineFailure = "unsupported" | "failed"; + +/** 把异常分类成「老后端没有这个出口」/「这次没读到」。取数层与文案层共用它作**唯一真源** + * (判据借 `lib/api` 的 `isNotImplemented`,与面板其余「未实现」分支同一口径)。 */ +export function classifyTimelineFailure(err: unknown): TimelineFailure { + return isNotImplemented(err) ? "unsupported" : "failed"; +} + +/** 折叠默认值:**用户没点过**的块 ⇒ 只有**最新一回合**默认展开(打开页面即看到最近的 + * 那条链路),其余折叠成索引;用户点过就**以用户的为准**(点开过的保持开、点收过的 + * 保持收)。`newest` 由调用方按**数据**判定(`rounds[0]`),与展示顺序无关。 */ +export function roundExpanded( + turnId: string, + expanded: Readonly>, + newest: boolean +): boolean { + const chosen = expanded[turnId]; + if (typeof chosen === "boolean") return chosen; + return newest; +} + +/** 点一下某个块:把它的展开态**翻转**(其余块不动)。返回新表,不就地改(React 状态)。 */ +export function toggleRoundExpansion( + expanded: Readonly>, + turnId: string, + newest: boolean +): Record { + if (!turnId) return { ...expanded }; + return { ...expanded, [turnId]: !roundExpanded(turnId, expanded, newest) }; +} + +/** 时间轴响应 → 取数层状态用的**纯**解包(缺键一律按诚实缺省:没有回合就是空数组、 + * `truncated` 缺席当 false、保留期缺席当未知)。 */ +export interface TimelinePage { + rounds: AdminFlowTimelineRound[]; + retentionHours: number | null; + truncated: boolean; +} + +/** 解包一页响应(**只读后端给的字段**:不补字段、不编值)。 */ +export function timelinePageOf( + resp: AdminFlowTimelineResponse | null | undefined +): TimelinePage { + return { + rounds: normalizeTimelineRounds(resp?.rounds), + retentionHours: + typeof resp?.retentionHours === "number" && Number.isFinite(resp.retentionHours) + ? resp.retentionHours + : null, + truncated: Boolean(resp?.truncated), + }; +} diff --git a/frontend/web/src/admin/modules/flow/useFlowTimeline.ts b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts new file mode 100644 index 0000000..116525b --- /dev/null +++ b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { adminFlowTimeline } from "../../../lib/api"; +import type { AdminFlowTimelineRound } from "../../../lib/api"; +import { + TIMELINE_PAGE_SIZE, + appendTimelinePage, + classifyTimelineFailure, + pageMayHaveOlder, + timelineCursor, + timelinePageOf, +} from "./flowTimeline"; + +/** + * 链路**时间轴**的取数 hook(切片 W2;`design/19 §7.1`;W1 的新出口 + * `GET /api/v1/admin/flow/timeline`)。 + * + * 分工:`useFlowSnapshot`(F1)补**最近一轮**、`useFlowTurns`(F2)翻**单轮历史**, + * 本 hook 一次给**多轮**(每轮连同它每一路),供时间轴视图把「所有消息按时间顺序」摊开。 + * 它**不写 store**(不碰 `flowGraphs` / `flowLanes`)—— 时间轴是**只读回看**,写进图桶 + * 会把正在跑的那一轮顶掉(裁决 16.4.2 的同一理由)。 + * + * 四条刻意口径: + * 1. **按需取数**(`enabled`):只有真的切到时间轴视图才打这个端点(默认不打扰后端); + * 2. **分页只向更早走**:游标 = 手上**最旧**那一回合的 `startedAt`(`flowTimeline.ts` + * 的纯函数),与展示顺序无关;一页没带来任何**新**回合 ⇒ 立刻判定到头(否则会在同 + * 一个游标上打转到天荒地老); + * 3. **老后端如实降级**:404/501 ⇒ `unsupported`(面板文案「当前只支持最近一轮」), + * **绝不**把「没有这个出口」说成「这段时间没有回合」; + * 4. **失败不伪装**:首屏失败 ⇒ `failed` + 可重试;翻页失败只记在 `moreFailed`, + * 已拿到的回合**照旧显示**(不回退成空态)。 + */ +export interface FlowTimelineState { + /** 保留期内的回合(**最新在前**,与展示顺序无关;渲染时再用 `orderRounds` 摆)。 */ + rounds: AdminFlowTimelineRound[]; + /** 全量回合数(= `rounds.length`;单独给一个名字,避免调用方各自取长度)。 */ + total: number; + /** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。 */ + retentionHours: number | null; + /** 更早的回合已过保留期(后端口径)—— 面板必须**如实标注**。 */ + truncated: boolean; + /** 首屏加载中(已有数据后翻页不算)。 */ + loading: boolean; + /** 还有更早的回合可拉(短页 / 游标不可用 / 已拉空 ⇒ false)。 */ + canLoadMore: boolean; + /** 正在拉更早的一页。 */ + loadingMore: boolean; + /** 后端**没有**这个出口(老后端)⇒ 「当前只支持最近一轮」。 */ + unsupported: boolean; + /** 首屏读取失败(网络 / 500 / 401)。 */ + failed: boolean; + /** 翻页失败(已拿到的回合照旧显示)。 */ + moreFailed: boolean; + /** 拉更早的一页。 */ + loadMore: () => void; + /** 重试首屏(失败 / 老后端修复后都走它)。 */ + reload: () => void; +} + +interface TimelineData { + sessionId: string; + rounds: AdminFlowTimelineRound[]; + retentionHours: number | null; + truncated: boolean; + loading: boolean; + loadingMore: boolean; + unsupported: boolean; + failed: boolean; + moreFailed: boolean; + canLoadMore: boolean; +} + +/** 空态初值(会话还没确定 / 视图还没切过来)。 */ +function emptyState(sessionId: string): TimelineData { + return { + sessionId, + rounds: [], + retentionHours: null, + truncated: false, + loading: false, + loadingMore: false, + unsupported: false, + failed: false, + moreFailed: false, + canLoadMore: false, + }; +} + +export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimelineState { + const target = enabled ? sessionId : ""; + const [state, setState] = useState(() => emptyState("")); + const [seq, setSeq] = useState(0); + const stateRef = useRef(state); + stateRef.current = state; + + // 首屏(会话变了 / 切到时间轴 / 手动重试)。已拉到数据的会话**不重复拉** —— 切视图来回 + // 不该每次都打后端;真要重拉有 `reload`。 + useEffect(() => { + if (!target) return; + const known = stateRef.current; + if (known.sessionId === target && !known.failed && !known.unsupported && known.rounds.length > 0) { + return; + } + return fetchFirstPage(target, setState); + }, [target, seq]); + + const loadMore = useCallback(() => { + const current = stateRef.current; + if (!current.sessionId || !current.canLoadMore || current.loadingMore || current.loading) return; + const sid = current.sessionId; + const cursor = timelineCursor(current.rounds); + if (!cursor) { + // 最旧那一回合没有可用的开始时间 ⇒ 没法构造游标 ⇒ 停住(不瞎凑游标), + // 由界面如实说明「没有更早的」。 + setState((s) => ({ ...s, canLoadMore: false })); + return; + } + fetchOlderPage(sid, cursor, setState); + }, []); + + const reload = useCallback(() => { + const sid = stateRef.current.sessionId; + setState(sid ? { ...emptyState(sid), loading: true } : emptyState("")); + setSeq((n) => n + 1); + }, []); + + return { + rounds: state.rounds, + total: state.rounds.length, + retentionHours: state.retentionHours, + truncated: state.truncated, + loading: state.loading, + canLoadMore: state.canLoadMore, + loadingMore: state.loadingMore, + unsupported: state.unsupported, + failed: state.failed, + moreFailed: state.moreFailed, + loadMore, + reload, + }; +} + +/** 取数体的类型(把「落进状态」这一动作注入,使下面两个取数函数不必认识 React)。 */ +type Send = (patch: (prev: TimelineData) => TimelineData) => void; + +/** 首屏取数:先置加载态,再把「一页 + 还能不能翻」落进状态;失败按 `classifyTimelineFailure` + * 分「老后端」与「这次没读到」两支。返回取消函数(effect 的 cleanup)。 */ +function fetchFirstPage(target: string, send: Send): () => void { + let cancelled = false; + send(() => ({ ...emptyState(target), loading: true })); + adminFlowTimeline(target, { limit: TIMELINE_PAGE_SIZE }) + .then((resp) => { + if (cancelled) return; + const page = timelinePageOf(resp); + send(() => ({ + ...emptyState(target), + ...page, + canLoadMore: pageMayHaveOlder(page.rounds.length, TIMELINE_PAGE_SIZE), + })); + }) + .catch((err: unknown) => { + if (cancelled) return; + const mark = classifyTimelineFailure(err); + send(() => ({ + ...emptyState(target), + unsupported: mark === "unsupported", + failed: mark === "failed", + })); + }); + return () => { + cancelled = true; + }; +} + +/** 取更早的一页:合并去重、保留期取「已拿到的优先」、`truncated` 一旦为真就不再回落 + * (过一个保留期边界就够了);失败只影响翻页,**已拿到的回合照旧显示**。 */ +function fetchOlderPage(sid: string, cursor: string, send: Send): void { + send((s) => (s.sessionId === sid ? { ...s, loadingMore: true, moreFailed: false } : s)); + adminFlowTimeline(sid, { limit: TIMELINE_PAGE_SIZE, before: cursor }) + .then((resp) => { + const page = timelinePageOf(resp); + send((s) => { + if (s.sessionId !== sid) return s; + const next = appendTimelinePage(s.rounds, page.rounds, TIMELINE_PAGE_SIZE); + return { + ...s, + rounds: next.rounds, + retentionHours: page.retentionHours ?? s.retentionHours, + truncated: s.truncated || page.truncated, + loadingMore: false, + canLoadMore: next.canLoadMore, + }; + }); + }) + .catch(() => { + send((s) => (s.sessionId === sid ? { ...s, loadingMore: false, moreFailed: true } : s)); + }); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index f97c765..46363f6 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -118,6 +118,7 @@ export { adminBackthink, adminConfigFacts, adminFlowGraph, + adminFlowTimeline, adminListNotifications, adminMemoryTidyDryRun, adminObservabilityAudit, @@ -130,4 +131,10 @@ export { adminSystemRotateJWT, adminSystemStorage, } from "./api/adminSystem"; -export type { AdminFlowGraphResponse, AdminFlowTurn } from "./api/adminSystem"; +export type { + AdminFlowGraphResponse, + AdminFlowLane, + AdminFlowTimelineResponse, + AdminFlowTimelineRound, + AdminFlowTurn, +} from "./api/adminSystem"; diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts index c28b312..5d8015a 100644 --- a/frontend/web/src/lib/api/adminSystem.ts +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -100,6 +100,57 @@ export function adminFlowGraph(sessionId: string, turnId?: string): Promise(`/admin/flow/graph${q ? `?${q}` : ""}`); } +/** 时间轴里的**一个回合**(`design/19 §7`;形状取自设计规格 —— W1 的出口)。 */ +export interface AdminFlowTimelineRound { + /** 该回合的权威标识。🔴 **一律用后端给的值**:面板既不自己拼 `{会话}-{序号}`, + * 也不从 `graph.id` 反解(契约 v0.79 起 `graph.id` 含 `#` 且单路也有后缀)。 */ + turnId: string; + /** 会话轮次号。**可能缺席 / `null`**(旧行、或后端尚未落这一列)⇒ 面板显示 + * 「轮次未知」,**绝不用 0 冒充**。 */ + round?: number | null; + /** 该回合的开始时间(RFC3339)。它是**时间轴排序与 `before` 游标的唯一依据**。 */ + startedAt: string; + /** 收尾时间。缺席 / `null` = 该回合**未收尾**(不许读成「已完成」)。 */ + endedAt?: string | null; + /** 该回合**每一路**的图(真并行时不止一条;`graph` 为 `null` = 那一路没有留下图)。 */ + lanes: AdminFlowLane[]; +} + +/** 链路**时间轴**端点的响应(`design/19 §7.1`;W1 新增的只读出口)。 */ +export interface AdminFlowTimelineResponse { + /** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */ + sessionId?: string; + /** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */ + rounds: AdminFlowTimelineRound[]; + /** `flow_graphs` 的保留期(小时)。`≤0` = 后端配置为不回收(见 `FLOW_GRAPH_RETENTION`)。 */ + retentionHours: number; + /** **更早的回合已过保留期**(`design/19 §7.1`)⇒ 面板必须**如实标注**,不许静默少块。 */ + truncated: boolean; +} + +/** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`; + * 只读、不审计,owner/admin 档)。它是老出口 `adminFlowGraph`(最近一轮 + 历史轮列表)的 + * **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串 + * 起来」的时间轴。 + * + * 分页:`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** —— 传「当前手上 + * 最旧那一回合的 `startedAt`(原样回传后端给的值)」即继续取更早的。⚠️ 本函数**不解释** + * `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端给的时间字符串。 + * + * 老后端(该出口未接线)⇒ `404`;调用方按「当前只支持最近一轮」诚实降级,别把 404 读成 + * 「这段时间没有回合」。 */ +export function adminFlowTimeline( + sessionId: string, + opts: { limit?: number; before?: string } = {} +): Promise { + const qs = new URLSearchParams(); + if (sessionId) qs.set("sessionId", sessionId); + if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit)); + if (opts.before) qs.set("before", opts.before); + const q = qs.toString(); + return request(`/admin/flow/timeline${q ? `?${q}` : ""}`); +} + /** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)): * 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的 * 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死),