feat(admin): Flow 页时间轴视图(W2)
- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新) - 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、 块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」 - 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」 - 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
This commit is contained in:
1 parent
1efe3a6a79
commit
5ee5f9126f
10 files changed
+1831
-127
No files matched your search
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 档;**不含任何凭据值**(后端有哨兵测试钉死),
|
||||
|
||||
Reference in new issue
Block a user