Files
Cyrene_v2/frontend/web/src/lib/api/adminSystem.ts
T
AskaEth 57d0e8c543
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 时间轴只读权威键 + 自排筛选轴 + browser 面板图标(Y2+Y3+Y4 前端一并落)
- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
  「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
2026-10-06 21:48:52 +08:00

324 lines
18 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?limit=&trigger= — persisted background-think records
* (newest first).
*
* `trigger` 是**服务端**筛选(契约 v0.82,切片 C2;`design/12 §C.6`):取值域 =
* 既有触发源 + `self_wake`(`heartbeat` / `startup` / `self_wake` / 事件主题
* `iot.device_update` / `reminder.trigger` / `system.event`)。⚠️ 后端对白名单外的
* 取值**如实 400**(`error.code = "invalid_trigger"`),**不静默忽略** —— 因此调用方
* 只能传自己确实认识的值;省略 = 不过滤(返回全部,老行为逐字不变)。
*
* ⚠️ 历史拉取的筛选结果**不含**实时帧:WS 的 `backthink` 事件始终是全量的,
* 面板必须**保留客户端筛选**兜住那一部分(见 Backthink.tsx)。 */
export function adminBackthink(limit?: number, trigger?: string): Promise<BackthinkItem[]> {
const qs = new URLSearchParams();
if (limit && limit > 0) qs.set("limit", String(limit));
if (trigger) qs.set("trigger", trigger);
const q = qs.toString();
return request<BackthinkItem[]>(`/admin/backthink${q ? `?${q}` : ""}`);
}
/** 自排闸的**进程生效**口径与代码默认值(契约 v0.82 §4.10(e))。
*
* ⚠️ `limitIsDefault` / `windowIsDefault` 只表示「生效值**恰好等于**代码默认值」,
* **不是**「`backthink.json` 里没写这个键」的断言(网关不读那个文件)。 */
export interface AdminSelfWakeGate {
/** 容量 1 的待跑槽是否被占用(有一轮自排排着队还没跑)。 */
pending: boolean;
/** 本窗口已用额度(与工具收据的 `used` 同一口径)。 */
used: number;
limit: number;
windowSec: number;
defaultLimit: number;
defaultWindowSec: number;
limitIsDefault: boolean;
windowIsDefault: boolean;
}
/** GET /api/v1/admin/backthink/self-wake 的响应(契约 v0.82 新增;只读、不审计,
* owner/admin 档)。
*
* 三态**必须分开显示**(把「看不到」画成「已用 0 / 上限 0」就是把猜当事实):
* - `available:false` ⇒ 本进程给不出快照(服务未接线 / 实现不提供读口 / 闸不在):
* `gate` **缺席**、`reason` 说明原因;
* - `available:true, running:false` ⇒ 已接线但不在运行(未启动或已停止):`gate`
* 仍带配置面数字(上限/窗口/默认值),但此刻排不出新的一轮 —— `reason` 说明;
* - `available:true, running:true` ⇒ 正常态。
*
* ⚠️ 它**不是写口**:调这两个旋钮要改 `$DATA_DIR/config/backthink.json` + 重启
* (启动期读一次);`configFile` 只是相对落点(`config/backthink.json`)。 */
export interface AdminSelfWakeStatus {
available: boolean;
running: boolean;
reason?: string;
configFile?: string;
gate?: AdminSelfWakeGate;
}
/** GET /api/v1/admin/backthink/self-wake — 自排闸的只读快照(本窗口已用 N / 上限 M)。
*
* 为什么有它:这个数字此前只出现在被拒那一轮的审计文案里(`backthink.self_wake.capped`
* 的 result),也就是「已经被拒过一次」才第一次看得见。它是纯读:不写库、不落审计、
* 读它不改变闸的状态。 */
export function adminSelfWakeStatus(): Promise<AdminSelfWakeStatus> {
return request<AdminSelfWakeStatus>("/admin/backthink/self-wake");
}
/** 一条**已落盘轮次**的摘要(契约 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[];
}
/** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;v0.81 增量)。
*
* 🔴 **`truncated` 不等于「已过保留期」**(契约 v0.81 §4.11.1(a) 的定稿口径):它的含义
* 是 **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。
* 「更早的回合已过保留期」这句话的判据**只能是** `retentionBoundaryReached === true`
* ——🔴 **不得**再拿 `truncated` + `retentionEnabled` + 「翻到底了没」三者自己拼(那正是
* v0.81 修掉的缺陷:一页还没拉完时面板会对着**仍然可翻**的历史说「已过保留期」)。 */
export interface AdminFlowTimelineResponse {
/** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */
sessionId?: string;
/** 本页**至少一轮**来自落盘 `persisted` / 本页**全部**来自内存 `live`(落盘源未接线,
* 或该会话还没有落盘轮)/ `none`(没有任何回合)。 */
source?: "persisted" | "live" | "none";
/** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */
rounds: AdminFlowTimelineRound[];
/** 本页**最早一轮**的开始时刻;`hasMore` 为真时**原样回带**成下一次请求的 `before`
* 即可连续向更早翻页(游标精度到毫秒)。没有回合时是空串。 */
nextBefore?: string;
/** 库里**还有更早的回合**没被本次返回(= 还能继续翻页)。**这是唯一权威的翻页判据**;
* 面板不要用「本页是否满页」去猜(满页的最后一页会被猜成「还有更早」,白打一次请求)。 */
hasMore?: boolean;
/** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」
* —— 面板据此标注「更早的轮次**未显示**」,说「已过保留期」请看下一行。 */
truncated: boolean;
/** 🆕 **v0.81**(契约 §4.11.1(a)):**该会话确有更早的轮次已被保留期清扫删掉**,
* 且本次已取到它现存最早的一端。**只有它为 `true` 时**才可以说「更早的轮次已过保留期」,
* 其余情形(含它 `false`)一律说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」
* (`source:"live"`)。
*
* ⚠️ 缺席(老后端)= **不知道** —— 一律按最保守话术处理(不说过期),**不**拿别的键回推。
* ⚠️ 它**少说也不说谎**:`false` **不等于**「没被清扫」(整段历史都被清扫 / 有轮次号未知
* 的老行 / 最早那一轮落盘失败,三种盲区见契约 §4.11.1(a) 表)。 */
retentionBoundaryReached?: boolean;
/** `flow_graphs` 的保留期(小时,**下取整**)。`≤0` = 后端配置为不回收。 */
retentionHours: number;
/** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。
* ⚠️ 它只说明「清扫开着」,**不说明「发生过清扫」**(判据见 `retentionBoundaryReached`)。 */
retentionEnabled?: boolean;
}
/** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`;
* 只读、不审计,owner/admin 档)。它是老出口 `adminFlowGraph`(最近一轮 + 历史轮列表)的
* **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串
* 起来」的时间轴。
*
* 分页(契约 §4.11.1(a)):`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标**
* —— 「开始时刻**严格早于**它」的回合才返回(省略 = 最新那一页)。游标取响应里的
* `nextBefore`(= 本页最早一轮的开始时刻)**原样回带**即可连续翻页,**不重不漏**。
* ⚠️ 本函数**不解释** `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端
* 给的时间字符串。⚠️ **也不要**用「本页是否满页」代替 `hasMore`:满页的最后一页会被误
* 判成「还有更早的」,白打一次请求(`hasMore` 才是权威判据)。
*
* 老后端(该出口未接线)⇒ `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 }),
});
}