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

233 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AdminLogLine,
AdminLogQuery,
AdminNotification,
AdminNotificationQuery,
AdminObservabilityMetrics,
AdminStorageStatus,
AdminTraceSpan,
AuditRecord,
AdminConfigFacts,
AdminTidyDryRunResult,
BackthinkItem,
FlowGraph,
SystemBackupResp,
SystemRestoreResp,
SystemRevokeKeysResp,
SystemRotateJwtResp,
} from "../types";
import { limitQuery, request } from "./core";
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
}
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/logs?level=&limit= — the gateway's structured
* log tail, newest first (owner/admin only). An omitted `level` means “all”; the
* backend defaults `limit` to 200 and caps it at 1000. */
export function adminObservabilityLogs(
params: AdminLogQuery = {}
): Promise<AdminLogLine[]> {
const qs = new URLSearchParams();
if (params.level) qs.set("level", params.level);
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminLogLine[]>(`/admin/observability/logs${q ? `?${q}` : ""}`);
}
/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */
export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
}
/** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮
* 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */
export interface AdminFlowTurn {
turnId: string;
createdAt: string;
}
/** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。
*
* `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此
* 渲染空态,绝不允许补画任何节点或假图。
*
* `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` =
* 从本地库读回的历史轮;`none` = 没有图。`turns` 是该会话已落盘的轮次摘要(最新在前),
* 没有时为 `[]`(后端保证非 null 数组)。 */
export interface AdminFlowGraphResponse {
sessionId: string;
turnId: string;
source: "live" | "persisted" | "none";
graph: FlowGraph | null;
/** 最近一轮**每一路**的图(契约 v0.77,纯增量;真并行 `chat.parallel` 时不止一条)。
* `graph` 只是其中最新的一条(向后兼容),`lanes` 才是全貌 —— 面板据此把两路并列
* 画出来,而不是让后起的那一路把前一路顶掉。没有图时是 `[]`(后端保证非 null)。 */
lanes: AdminFlowLane[];
turns: AdminFlowTurn[];
}
/** `lanes[]` 里的一条路:`turnId` 是该路的权威回合标识(design/15 §2.5.9),
* `graph` 与响应的 `graph` / 帧的 data 逐字同形。 */
export interface AdminFlowLane {
turnId: string;
graph: FlowGraph | null;
}
/** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约
* v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。
*
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮
* 快照」;v0.73 把图**每轮落盘**,于是可以:
* - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新);
* - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。
*
* `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人)
* 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */
export function adminFlowGraph(sessionId: string, turnId?: string): Promise<AdminFlowGraphResponse> {
const qs = new URLSearchParams();
if (sessionId) qs.set("sessionId", sessionId);
if (turnId) qs.set("turnId", turnId);
const q = qs.toString();
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 档;**不含任何凭据值**(后端有哨兵测试钉死),
* 网关也**不读 `.env`** —— 值就是运行实例真正在用的数。 */
export function adminConfigFacts(): Promise<AdminConfigFacts> {
return request<AdminConfigFacts>("/admin/config-facts");
}
/** POST /api/v1/admin/memories/tidy/dry-run — 记忆整理「试算」(契约 v0.75 §4.12)。
*
* 它跑**同一套**整理规则(与低峰定时轮共用代码路径)并把候选直接回在响应里,
* 但**一行都不写**(不写候选表/运行表、不改任何记忆)—— 因此可以在任何时刻、
* 在整理器第一次开跑之前,先看清「它会建议合并哪些」。
*
* ⚠️ 它不是看板数据源:试算是一次真实的规则计算(扫描 + 两两召回 + 包含判定),
* **只在用户点击时调用,不要轮询**。
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_recall_unavailable /
* tidy_timeout),**不会**回 `candidates: []` —— 空数组只表示「算过了,没有建议」。 */
export function adminMemoryTidyDryRun(): Promise<AdminTidyDryRunResult> {
return request<AdminTidyDryRunResult>("/admin/memories/tidy/dry-run", { method: "POST" });
}
/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification
* feed (design/10 §3 #16: audit + channel + backthink + system aggregated).
* `type` omitted = all types, `before` = createdAt(ms) cursor for older pages,
* `limit` defaults to 50 (backend caps at 200). Returned newest first. */
export function adminListNotifications(
params: AdminNotificationQuery = {}
): Promise<AdminNotification[]> {
const qs = new URLSearchParams();
if (params.type) qs.set("type", params.type);
if (params.before !== undefined) qs.set("before", String(params.before));
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminNotification[]>(`/admin/notifications${q ? `?${q}` : ""}`);
}
/* ----------------------------------------------------------------------------
* System management admin writes (owner-only, audited). Reference:
* internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约.
* All four are implemented (not 501) — they return real data.
* -------------------------------------------------------------------------- */
/** GET /api/v1/admin/system/storage — the **read-only** storage snapshot behind the
* dashboard's 存储 card (v0.60): SQLite file/WAL/page stats, the documents table's
* count+bytes, and whether the optional pgvector enhancement is configured and
* actually live. The `vector` block never carries credentials (no DSN, no password).
* owner-only; the backend answers `500 storage_unavailable` rather than a fake
* all-zero snapshot when the live DB handle is not wired. */
export function adminSystemStorage(): Promise<AdminStorageStatus> {
return request<AdminStorageStatus>("/admin/system/storage");
}
/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */
export function adminSystemRevokeKeys(): Promise<SystemRevokeKeysResp> {
return request<SystemRevokeKeysResp>("/admin/system/revoke-keys", { method: "POST" });
}
/** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars).
* The secret is provided by the owner and never echoed back; all existing tokens
* become invalid, so the client must re-login afterward. */
export function adminSystemRotateJWT(newSecret: string): Promise<SystemRotateJwtResp> {
return request<SystemRotateJwtResp>("/admin/system/rotate-jwt", {
method: "POST",
body: JSON.stringify({ newSecret }),
});
}
/** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */
export function adminSystemBackup(): Promise<SystemBackupResp> {
return request<SystemBackupResp>("/admin/system/backup", { method: "POST" });
}
/** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live
* inside the data directory (path-traversal guard). The backend pre-backups the
* current DB and reports that the swap needs a restart. */
export function adminSystemRestore(path: string): Promise<SystemRestoreResp> {
return request<SystemRestoreResp>("/admin/system/restore", {
method: "POST",
body: JSON.stringify({ path }),
});
}