feat(admin): Flow 页时间轴视图(W2)

- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新)
- 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、
  块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」
- 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」
- 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
This commit is contained in:
AskaEth committed 2026-10-06 15:59:11 +08:00
1 parent 1efe3a6a79
commit 5ee5f9126f
10 files changed
+1831 -127

No files matched your search

+259 -113
View File
@@ -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<FlowView>("latest");
const [order, setOrder] = useState<FlowTimelineOrder>("newest-first");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
// 取数接线(纯函数,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 (
<FlowBody
graph={graph}
@@ -67,6 +111,10 @@ export function FlowModule(): React.ReactNode {
onSelectTurn={history.selectTurn}
historyGraph={history.historyGraph}
historyLoading={history.historyLoading}
view={view}
onSelectView={setView}
timeline={timeline}
timelineBinding={binding}
/>
);
}
@@ -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 (
<div className="space-y-5">
<PageHeader
title="全链路流程"
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史;视图可切「时间轴」(保留期内多轮按时间顺序)"
/>
{latest.onSelectTurn || onSelectSession || onSelectView ? (
<Surface className="space-y-2">
{onSelectView ? <FlowViewTabs view={view} onSelect={onSelectView} /> : null}
{onSelectSession ? (
<FlowSessionPicker
options={sessionOptions ?? []}
value={latest.sessionId}
onSelect={onSelectSession}
/>
) : null}
{/* 历史轮选择器只在「最近一轮」视图下渲染 —— 时间轴自己有分页口径,
两套轮次控制同时在场只会让人分不清「翻的是哪一套」。 */}
{view === "latest" && latest.onSelectTurn ? (
<FlowTurnPicker
turns={latest.turns ?? []}
selectedTurnId={latest.selectedTurnId ?? LATEST_TURN}
onSelect={latest.onSelectTurn}
loading={latest.historyLoading ?? false}
/>
) : null}
{view === "latest" && showingHistory && shown ? (
<FlowHistoryHint turnId={shown.id} />
) : null}
</Surface>
) : null}
{view === "timeline" ? (
timeline && timelineBinding ? (
<FlowTimelineView
state={timeline}
sessionId={latest.sessionId}
binding={timelineBinding}
/>
) : (
<Notice>
时间轴取数未接线(本页只喂了「最近一轮」的数据)—— 请切回上方「最近一轮」视图。
</Notice>
)
) : (
<LatestRound {...latest} />
)}
</div>
);
}
/** 「最近一轮」视图的**全部** 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 (
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span className="font-medium">视图</span>
{VIEW_TABS.map((tab) => (
<button
key={tab.id}
type="button"
title={tab.hint}
aria-pressed={view === tab.id}
onClick={() => onSelect(tab.id)}
className={`rounded-[var(--mu-radius-sm)] border px-2 py-1 text-xs ${
view === tab.id
? "border-[var(--mu-accent)] bg-[var(--mu-surface-2)] text-[var(--mu-text-hi)]"
: "border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] text-[var(--mu-text-mid)]"
}`}
>
{tab.label}
</button>
))}
<span className="text-[11px] text-[var(--mu-text-lo)]">
{VIEW_TABS.find((t) => t.id === view)?.hint ?? ""}
</span>
</div>
);
}
/** 「最近一轮」视图的内容区(既有行为,逐字保留):有图就画整页、没图就诚实空态。
*
* **来源与轮次**(契约 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 (
<Notice>
{showingHistory
? historyLoading
? `正在读取历史轮 ${selectedTurnId} 的链路图……`
: `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。`
: "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"}
</Notice>
);
}
return (
<div className="space-y-5">
<PageHeader
title="全链路流程"
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史"
/>
<>
<CardGrid>
<StatCard
icon={<Icon name="chat" size={18} />}
label="会话"
value={<span className="text-base">{shown.sessionId ?? "web"}</span>}
sub={shown.id}
tone="accent"
/>
<StatCard
icon={<Icon name="agent" size={18} />}
label="节点"
value={shown.nodes.length}
sub={`${shown.edges.length} 条连线`}
tone="neutral"
/>
<StatCard
icon={<Icon name="trend" size={18} />}
label="泳道"
value={laneCount > 0 ? laneCount : "—"}
sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"}
tone="accent"
/>
<StatCard
icon={<Icon name="clock" size={18} />}
label="耗时"
value={fmtDuration(shown)}
sub={`开始 ${fmtStart(shown)}`}
tone="ok"
/>
</CardGrid>
{onSelectTurn || onSelectSession ? (
<Surface className="space-y-2">
{onSelectSession ? (
<FlowSessionPicker
options={sessionOptions ?? []}
value={sessionId}
onSelect={onSelectSession}
/>
) : null}
{onSelectTurn ? (
<FlowTurnPicker
turns={turns}
selectedTurnId={selectedTurnId}
onSelect={onSelectTurn}
loading={historyLoading}
/>
) : null}
{showingHistory && shown ? <FlowHistoryHint turnId={shown.id} /> : null}
</Surface>
) : null}
{shown ? (
<>
<CardGrid>
<StatCard
icon={<Icon name="chat" size={18} />}
label="会话"
value={<span className="text-base">{shown.sessionId ?? "web"}</span>}
sub={shown.id}
tone="accent"
/>
<StatCard
icon={<Icon name="agent" size={18} />}
label="节点"
value={shown.nodes.length}
sub={`${shown.edges.length} 条连线`}
tone="neutral"
/>
<StatCard
icon={<Icon name="trend" size={18} />}
label="泳道"
value={laneCount > 0 ? laneCount : "—"}
sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"}
tone="accent"
/>
<StatCard
icon={<Icon name="clock" size={18} />}
label="耗时"
value={fmtDuration(shown)}
sub={`开始 ${fmtStart(shown)}`}
tone="ok"
/>
</CardGrid>
{parallelLanes.length > 0 ? (
<LaneTracks
lanes={parallelLanes}
{parallelLanes.length > 0 ? (
<LaneTracks
lanes={parallelLanes}
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
) : (
<Surface className="space-y-3">
<StatusStrip graph={shown} />
<FlowGraphView
graph={shown}
orientation="newest-top"
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
{showingHistory ? null : (
<FlowSnapshotHint
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
) : (
<Surface className="space-y-3">
<StatusStrip graph={shown} />
<FlowGraphView
graph={shown}
orientation="newest-top"
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
{showingHistory ? null : (
<FlowSnapshotHint
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
)}
</Surface>
)}
{selected && parallelLanes.length === 0 ? (
<NodeDetail
node={selected}
laneLabel={selectedLane}
onClose={() => setSelectedId(null)}
/>
) : null}
</>
) : (
<Notice>
{showingHistory
? historyLoading
? `正在读取历史轮 ${selectedTurnId} 的链路图……`
: `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。`
: "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"}
</Notice>
</Surface>
)}
</div>
{selected && parallelLanes.length === 0 ? (
<NodeDetail
node={selected}
laneLabel={selectedLane}
onClose={() => setSelectedId(null)}
/>
) : null}
</>
);
}
@@ -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 (
<div className="rounded-[var(--mu-radius)] border border-dashed border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
{source === "snapshot" ? (
{source === "timeline" ? (
<>
以上是 <span className="text-[var(--mu-text-mid)]">{label}</span>(
<code>{sessionId}</code>)的
<span className="text-[var(--mu-text-mid)]">这一个回合</span>
的链路图 —— 时间轴从<b>本地落盘</b>按时间读回(
<code>GET /api/v1/admin/flow/timeline</code>,只读)。保留期内的回合才在;已过保留期的
回合不在时间轴上(页首会标注)。它<b>不是</b>实时帧。
</>
) : source === "snapshot" ? (
<>
以上是 <span className="text-[var(--mu-text-mid)]">{label}</span>(
<code>{sessionId}</code>)的
@@ -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 (
<div className="space-y-4">
@@ -51,7 +50,7 @@ function LaneTrack({
/** 展示用的路序号(1 基,最旧的为 1)。 */
index: number;
sessionId: string;
source: FlowGraphSource;
source: FlowHintSource;
}): React.ReactNode {
const [selectedId, setSelectedId] = useState<string | null>(null);
const grouping = groupFlowLanes(lane);
@@ -76,7 +75,7 @@ function LaneTrack({
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping.lanes} onSelect={setSelectedId} />
<FlowSnapshotHint sessionId={lane.sessionId ?? sessionId} source={source ?? "live"} />
<FlowSnapshotHint sessionId={lane.sessionId ?? sessionId} source={source} />
{selected ? (
<NodeDetail node={selected} laneLabel={undefined} onClose={() => setSelectedId(null)} />
) : null}
@@ -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 (
<div className="space-y-4">
<TimelineHeader state={state} binding={binding} />
{shown.length > 0 ? (
<ol className="space-y-4" data-flow-timeline="rounds">
{shown.map((round, i) => (
<TimelineRoundBlock
key={keyOf(round, i)}
round={round}
sessionId={sessionId}
newest={newest === round}
expanded={roundExpanded(round.turnId, binding.expanded, newest === round)}
onToggle={binding.onToggleRound}
/>
))}
</ol>
) : (
<TimelineEmpty state={state} onReload={binding.onReload} />
)}
</div>
);
}
/** 时间轴视图的交互接线(`Flow.tsx` 持有状态;渲染测试可喂静态值)。 */
export interface TimelineBinding {
order: FlowTimelineOrder;
onToggleOrder: () => void;
/** 用户点过的展开态(`turnId → 展开?`);没点过的按「最新那回合默认展开」算。 */
expanded: Readonly<Record<string, boolean>>;
onToggleRound: (turnId: string) => void;
/** 重试首屏(失败 / 老后端修复后)。 */
onReload: () => void;
}
/** 块标题之外的页首:顺序、分页、保留期口径、以及「不臆造关系」的明示。 */
function TimelineHeader({
state,
binding,
}: {
state: FlowTimelineState;
binding: TimelineBinding;
}): React.ReactNode {
return (
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-mid)]">
<span className="font-medium">时间轴</span>
<span className="text-[var(--mu-text-lo)]">共 {state.total} 个回合</span>
<button
type="button"
onClick={binding.onToggleOrder}
className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs text-[var(--mu-text-mid)]"
>
{binding.order === "newest-first" ? "最新在上" : "最新在下"}
</button>
{state.canLoadMore ? (
<button
type="button"
onClick={state.loadMore}
disabled={state.loadingMore}
className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs text-[var(--mu-text-mid)]"
>
{state.loadingMore ? "正在读取更早的回合…" : "加载更早的回合"}
</button>
) : state.total > 0 ? (
<span className="text-[var(--mu-text-lo)]">没有更早的回合了</span>
) : null}
{state.moreFailed ? (
<span className="text-[var(--mu-err)]">更早的回合这次没读到(已拿到的照旧显示)</span>
) : null}
</div>
<p className="text-[11px] text-[var(--mu-text-lo)]">
时间轴只按<b>时间与序号</b>排列:块与块之间<b>没有因果连线</b>(不臆造关系)——
「上一块刚结束、下一块接着开始」只是时间相邻,不等于谁引发了谁。
</p>
<p className="text-[11px] text-[var(--mu-text-lo)]">
{retentionText(state.retentionHours)}
{state.truncated ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null}
 · 数据源:<code>GET /api/v1/admin/flow/timeline</code>(只读回看,不写库、不发布帧)
</p>
</Surface>
);
}
/** 没有回合时的**诚实空态**:老后端 / 读取失败 / 确实为空,三件事**分开说**。 */
function TimelineEmpty({
state,
onReload,
}: {
state: FlowTimelineState;
onReload: () => void;
}): React.ReactNode {
return (
<div className="space-y-2 rounded-[var(--mu-radius)] border border-dashed border-[var(--mu-glass-border)] px-3 py-3 text-xs text-[var(--mu-text-mid)]">
{state.unsupported ? (
<p>
<b>当前只支持最近一轮</b> —— 本后端没有链路时间轴出口
(<code>GET /api/v1/admin/flow/timeline</code> 未接线)。请切回上方的「最近一轮」视图看
最新那一轮;时间轴需要后端升级(老后端**不是**「这段时间没有回合」)。
</p>
) : state.failed ? (
<p>
链路时间轴暂时读不到(请求失败)—— <b>这不等于</b>「这段时间没有回合」。
<button
type="button"
onClick={onReload}
className="ml-2 rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-0.5 text-xs"
>
重试
</button>
</p>
) : state.loading ? (
<p className="mu-blink">正在读取链路时间轴……</p>
) : (
<p>
保留期内没有可回看的回合 —— 可能是该会话还没跑过带 span 的轮次,或(
{retentionText(state.retentionHours)})之外的都已被回收。发布会话白名单之外的会话
(群聊 / 陌生人的 <code>sess-*</code>)从不产出链路图,那不是缺陷。
</p>
)}
</div>
);
}
/** **一个回合块**:标题(时间 · 状态 · 轮次 · 回合标识 · 观测)+ 折叠的块内泳道。
*
* 标题里每一件都是**可查的事实**:时间是后端给的开始时间(不可解析时是 `—`)、轮次号
* 缺席就是「轮次未知」、回合标识来自后端(短标识只做截断展示,完整值在 `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 (
<li className="relative pl-4" data-flow-timeline-round={round.turnId}>
{/* 竖轴(时间轴本体):一条中性竖线与一个锚点,**不指向**下一块 —— 只表示时间。 */}
<span
aria-hidden="true"
className="absolute bottom-0 left-0 top-0 w-px bg-[var(--mu-glass-border)]"
data-flow-timeline-axis="1"
/>
<span
aria-hidden="true"
className="absolute left-[-2px] top-4 h-1.5 w-1.5 rounded-full bg-[var(--mu-surface-5)]"
/>
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2 px-1 text-xs text-[var(--mu-text-mid)]">
<span className="font-mono" title={round.startedAt || undefined}>
{fmtRoundTime(round.startedAt)}
</span>
<Tag tone={roundStatusTag(status)}>{roundStatusLabel(status)}</Tag>
<span className="text-[var(--mu-text-lo)]">{roundLabel(round.round)}</span>
<span className="text-[var(--mu-text-lo)]" title={round.turnId || undefined}>
回合 <span className="font-mono">{shortTurnId(round.turnId)}</span>
</span>
<span className="text-[var(--mu-text-lo)]">
{stats.legs} 路 · {stats.nodes} 节点
</span>
{newest ? <Tag tone="accent">最新</Tag> : null}
<button
type="button"
onClick={() => onToggle(round.turnId)}
aria-expanded={expanded}
className="ml-auto rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-0.5 text-xs"
>
{expanded ? "折叠" : "展开"}
</button>
</div>
{expanded ? (
<RoundBody round={round} sessionId={sessionId} stats={stats} graphs={graphs} />
) : (
<p className="px-1 text-[11px] text-[var(--mu-text-lo)]">
已折叠 —— 点「展开」看这一回合各路的链路图。
</p>
)}
</Surface>
</li>
);
}
/** 展开态下的块内:该回合**每一路**的并列泳道(复用 `LaneTracks`)+ 诚实缺席提示。 */
function RoundBody({
round,
sessionId,
stats,
graphs,
}: {
round: AdminFlowTimelineRound;
sessionId: string;
stats: RoundStats;
graphs: readonly FlowGraph[];
}): React.ReactNode {
if (graphs.length === 0) {
return (
<p className="px-1 text-xs text-[var(--mu-text-lo)]">
这一回合没有可画的图(后端给的是空(<code>graph:null</code>)—— 可能是该轮没有跑
span,或已过保留期)。
</p>
);
}
return (
<>
<LaneTracks lanes={graphs} sessionId={sessionId} source="timeline" />
{stats.missing > 0 ? (
<p className="px-1 text-[11px] text-[var(--mu-text-lo)]">
这一回合后端报了 {stats.legs} 路,其中 {stats.missing} 路没有留下图(如实缺席,不补画)。
</p>
) : null}
</>
);
}
/** React key:优先用后端给的回合标识;标识缺席时退到序号(只为渲染,**不写进界面**)。 */
function keyOf(round: AdminFlowTimelineRound, index: number): string {
return round.turnId || `#${index}`;
}
@@ -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<T>(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);
}
/** 一路的图:`哨兵<mark>` 节点用来在页面上认这一路(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<FlowGraph | null>;
} = {}
): 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> = {}
): 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> = {}): TimelineBinding {
return {
order: "newest-first",
onToggleOrder: () => {},
expanded: {},
onToggleRound: () => {},
onReload: () => {},
...over,
};
}
function view(
state: FlowTimelineState,
over: Partial<TimelineBinding> = {},
sessionId = MAIN_SESSION_ID
): string {
return renderToStaticMarkup(
createElement(FlowTimelineView, { state, sessionId, binding: binding(over) })
);
}
/** SSR 里每个回合块的区间(`<li data-flow-timeline-round="…">…</li>`)。 */
interface Block {
turnId: string;
start: number;
end: number;
html: string;
}
function blocks(markup: string): Block[] {
const out: Block[] = [];
const re = /<li[^>]*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</span>"), "页面上不铺完整回合标识(短标识 + 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(!/<svg|<path|<span|<div/i.test(head), "第一个块之前没有任何图形元素(时间轴不含块外连线)");
const tail = base.slice(bs[bs.length - 1].end);
ok(!/<svg|<path|<span|<div/i.test(tail), "最后一个块之后没有任何图形元素(时间轴不含块外连线)");
ok(has(base, 'data-flow-timeline-axis="1"'), "竖轴(时间轴本体)在,且只是装饰性元素");
ok(!/<(svg|path|line)[^>]*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`);
@@ -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<T>(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<FlowGraph | null> } = {}
): 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<string> {
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`);
@@ -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<string>();
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<AgentStatus>();
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<AgentStatus, string> = {
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<Record<string, boolean>>,
newest: boolean
): boolean {
const chosen = expanded[turnId];
if (typeof chosen === "boolean") return chosen;
return newest;
}
/** 点一下某个块:把它的展开态**翻转**(其余块不动)。返回新表,不就地改(React 状态)。 */
export function toggleRoundExpansion(
expanded: Readonly<Record<string, boolean>>,
turnId: string,
newest: boolean
): Record<string, boolean> {
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),
};
}
@@ -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<TimelineData>(() => 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));
});
}
+8 -1
View File
@@ -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";
+51
View File
@@ -100,6 +100,57 @@ export function adminFlowGraph(sessionId: string, turnId?: string): Promise<Admi
return request<AdminFlowGraphResponse>(`/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<AdminFlowTimelineResponse> {
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<AdminFlowTimelineResponse>(`/admin/flow/timeline${q ? `?${q}` : ""}`);
}
/** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)):
* 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的
* 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死),